neco-editor 0.1.0

Umbrella crate for editor runtime primitives with a unified text buffer
Documentation
pub use neco_decor;
pub use neco_diffcore;
pub use neco_filetree;
pub use neco_history;
pub use neco_pathrel;
pub use neco_textpatch;
pub use neco_textview;
pub use neco_tree;
pub use neco_watchnorm;
pub use neco_wrap;

pub use neco_textview::RangeChange;

use neco_decor::DecorationSet;
use neco_history::EditHistory;
use neco_textpatch::{TextPatch, TextPatchError};
use neco_textview::LineIndex;
use neco_wrap::{WrapMap, WrapPolicy};

pub struct EditorBuffer {
    text: String,
    line_index: LineIndex,
}

impl EditorBuffer {
    pub fn new(text: String) -> Self {
        Self {
            line_index: LineIndex::new(&text),
            text,
        }
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn line_index(&self) -> &LineIndex {
        &self.line_index
    }

    /// Apply patches, update text and LineIndex, return RangeChanges for downstream consumers.
    pub fn apply_patches(
        &mut self,
        patches: &[TextPatch],
    ) -> Result<Vec<RangeChange>, TextPatchError> {
        let new_text = neco_textpatch::apply_patches(self.text(), patches)?;
        let range_changes = build_range_changes(patches);
        self.text = new_text;
        self.line_index = LineIndex::new(&self.text);
        Ok(range_changes)
    }

    /// Apply patches and propagate to all subsystems in order:
    /// 1. Record to history (before text change, needs original text for inverse patch computation)
    /// 2. Apply patches to text and rebuild LineIndex
    /// 3. Map decorations through changes
    /// 4. Update wrap map (only when both wrap_map and wrap_policy are Some)
    pub fn apply_patches_with(
        &mut self,
        patches: &[TextPatch],
        decorations: &mut DecorationSet,
        wrap_map: Option<&mut WrapMap>,
        wrap_policy: Option<&WrapPolicy>,
        history: Option<&mut EditHistory>,
        label: Option<&str>,
    ) -> Result<(), TextPatchError> {
        let old_line_index = if wrap_map.is_some() {
            Some(self.line_index.clone())
        } else {
            None
        };

        if let Some(history) = history {
            history.push_edit(label.unwrap_or(""), self.text(), patches.to_vec());
        }

        let range_changes = self.apply_patches(patches)?;
        decorations.map_through_changes(&range_changes);

        if let (Some(wrap_map), Some(wrap_policy)) = (wrap_map, wrap_policy) {
            update_wrap_map(
                wrap_map,
                wrap_policy,
                old_line_index
                    .as_ref()
                    .expect("old_line_index set when wrap_map is Some"),
                &self.text,
                &self.line_index,
                patches,
                &range_changes,
            );
        }

        Ok(())
    }
}

fn build_range_changes(patches: &[TextPatch]) -> Vec<RangeChange> {
    let mut ordered = patches.iter().enumerate().collect::<Vec<_>>();
    ordered.sort_by(|(left_index, left_patch), (right_index, right_patch)| {
        left_patch
            .start()
            .cmp(&right_patch.start())
            .then_with(|| left_patch.end().cmp(&right_patch.end()))
            .then_with(|| left_index.cmp(right_index))
    });

    let mut cumulative_delta = 0i64;
    let mut changes = Vec::with_capacity(ordered.len());

    for (_, patch) in ordered {
        let patch_start = i64::try_from(patch.start()).expect("patch start exceeds i64::MAX");
        let patch_end = i64::try_from(patch.end()).expect("patch end exceeds i64::MAX");
        let replacement_len =
            i64::try_from(patch.replacement().len()).expect("replacement len exceeds i64::MAX");

        let adjusted_start = usize::try_from(patch_start + cumulative_delta)
            .expect("validated patch start should stay non-negative");
        let adjusted_old_end = usize::try_from(patch_end + cumulative_delta)
            .expect("validated patch end should stay non-negative");
        let adjusted_new_end = adjusted_start
            .checked_add(usize::try_from(replacement_len).expect("replacement len exceeds usize"))
            .expect("range change new end overflow");

        changes.push(RangeChange::new(
            adjusted_start,
            adjusted_old_end,
            adjusted_new_end,
        ));

        cumulative_delta += replacement_len - (patch_end - patch_start);
    }

    changes
}

fn update_wrap_map(
    wrap_map: &mut WrapMap,
    wrap_policy: &WrapPolicy,
    old_line_index: &LineIndex,
    new_text: &str,
    new_line_index: &LineIndex,
    patches: &[TextPatch],
    range_changes: &[RangeChange],
) {
    if patches.is_empty() {
        return;
    }

    let start_offset = patches.iter().map(TextPatch::start).min().unwrap_or(0);
    let old_end_offset = patches
        .iter()
        .map(TextPatch::end)
        .max()
        .unwrap_or(start_offset);
    let new_end_offset = range_changes
        .iter()
        .map(RangeChange::new_end)
        .max()
        .unwrap_or(start_offset);

    let start_line = old_line_index
        .line_of_offset(start_offset)
        .expect("validated patch start should map to a line");
    let old_end_line = old_line_index
        .line_of_offset(old_end_offset)
        .expect("validated patch end should map to a line");
    let new_end_line = new_line_index
        .line_of_offset(new_end_offset)
        .expect("validated patch end should map to a line");

    let old_line_count = old_end_line - start_line + 1;
    let new_line_count = new_end_line - start_line + 1;

    if old_line_count == new_line_count {
        for line in start_line..=new_end_line {
            let line_text = line_text(new_text, new_line_index, line);
            wrap_map.rewrap_line(line, line_text, wrap_policy);
        }
        return;
    }

    // start_line is computed from old_line_index. Because patches are sorted
    // by start offset and applied front-to-back, no prior patch can shift the
    // line number of the earliest affected offset. start_line is therefore
    // valid in both old and new coordinate spaces.
    let new_lines = collect_line_texts(new_text, new_line_index, start_line, new_line_count);

    wrap_map.splice_lines(
        start_line,
        old_line_count,
        new_lines.into_iter(),
        wrap_policy,
    );
}

fn collect_line_texts<'a>(
    text: &'a str,
    line_index: &LineIndex,
    start_line: u32,
    line_count: u32,
) -> Vec<&'a str> {
    (start_line..start_line + line_count)
        .map(|line| line_text(text, line_index, line))
        .collect()
}

fn line_text<'a>(text: &'a str, line_index: &LineIndex, line: u32) -> &'a str {
    let range = line_index
        .line_range(line)
        .expect("line should be in range for wrap update");
    &text[range.start()..range.end()]
}

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

    use neco_decor::Decoration;
    use neco_textpatch::apply_patches;

    #[test]
    fn new_exposes_text_and_line_index() {
        let buffer = EditorBuffer::new("alpha\nbeta".to_string());

        assert_eq!(buffer.text(), "alpha\nbeta");
        assert_eq!(buffer.line_index().line_count(), 2);
        assert_eq!(buffer.line_index().text_len(), 10);
    }

    #[test]
    fn apply_patches_updates_text_and_returns_single_range_change() {
        let mut buffer = EditorBuffer::new("hello world".to_string());
        let patches = [TextPatch::replace(6, 11, "rust").unwrap()];

        let changes = buffer.apply_patches(&patches).unwrap();

        assert_eq!(buffer.text(), "hello rust");
        assert_eq!(changes, vec![RangeChange::new(6, 11, 10)]);
        assert_eq!(buffer.line_index().text_len(), 10);
    }

    #[test]
    fn apply_patches_uses_cumulative_delta_for_following_changes() {
        let mut buffer = EditorBuffer::new("abcdef".to_string());
        let patches = [
            TextPatch::replace(1, 3, "WXYZ").unwrap(),
            TextPatch::replace(4, 6, "Q").unwrap(),
        ];

        let changes = buffer.apply_patches(&patches).unwrap();

        assert_eq!(buffer.text(), "aWXYZdQ");
        assert_eq!(
            changes,
            vec![RangeChange::new(1, 3, 5), RangeChange::new(6, 8, 7)]
        );
    }

    #[test]
    fn apply_patches_returns_error_for_invalid_patch() {
        let mut buffer = EditorBuffer::new("abc".to_string());
        let patches = [TextPatch::replace(4, 4, "x").unwrap()];

        let error = buffer.apply_patches(&patches).unwrap_err();

        assert_eq!(
            error,
            TextPatchError::OffsetOutOfBounds { offset: 4, len: 3 }
        );
    }

    #[test]
    fn apply_patches_with_maps_decorations_through_changes() {
        let mut buffer = EditorBuffer::new("hello world".to_string());
        let patches = [TextPatch::replace(6, 11, "rust").unwrap()];
        let mut decorations = DecorationSet::new();
        decorations.add(Decoration::highlight(6, 11, 1).unwrap());

        buffer
            .apply_patches_with(&patches, &mut decorations, None, None, None, None)
            .unwrap();

        let decoration = decorations.iter().next().unwrap().1;
        assert_eq!(decoration.start(), 6);
        assert_eq!(decoration.end(), 10);
    }

    #[test]
    fn apply_patches_with_updates_wrap_map() {
        let mut buffer = EditorBuffer::new("ab cd\nef gh".to_string());
        let patches = [TextPatch::replace(0, 5, "abcd").unwrap()];
        let policy = WrapPolicy::code();
        let mut decorations = DecorationSet::new();
        let mut wrap_map = WrapMap::new(buffer.text().split('\n'), 3, &policy);

        assert_eq!(wrap_map.visual_line_count(0), 2);

        buffer
            .apply_patches_with(
                &patches,
                &mut decorations,
                Some(&mut wrap_map),
                Some(&policy),
                None,
                None,
            )
            .unwrap();

        assert_eq!(wrap_map.visual_line_count(0), 1);
        assert_eq!(wrap_map.wrap_points(0), &[]);
    }

    #[test]
    fn apply_patches_with_records_history_and_undo_restores_original_text() {
        let mut buffer = EditorBuffer::new("hello world".to_string());
        let patches = [TextPatch::replace(6, 11, "rust").unwrap()];
        let mut decorations = DecorationSet::new();
        let mut history = EditHistory::new(buffer.text());

        buffer
            .apply_patches_with(
                &patches,
                &mut decorations,
                None,
                None,
                Some(&mut history),
                Some("replace word"),
            )
            .unwrap();

        assert_eq!(history.current_label(), "replace word");

        let undo = history.undo().unwrap();
        let inverse = undo.inverse_patches.unwrap();
        let restored = apply_patches(buffer.text(), &inverse).unwrap();

        assert_eq!(restored, "hello world");
    }

    #[test]
    fn apply_patches_with_works_when_all_optional_systems_are_absent() {
        let mut buffer = EditorBuffer::new("hello".to_string());
        let patches = [TextPatch::insert(5, "!")];
        let mut decorations = DecorationSet::new();

        buffer
            .apply_patches_with(&patches, &mut decorations, None, None, None, None)
            .unwrap();

        assert_eq!(buffer.text(), "hello!");
    }

    #[test]
    fn re_exports_are_available() {
        let _ = neco_textview::LineIndex::new("text");
        let _ = neco_textpatch::TextPatch::insert(0, "x");
        let _ = neco_decor::DecorationSet::new();
        let _ = neco_diffcore::diff("a", "b");
        let _ = neco_wrap::WrapPolicy::code();
        let _ = neco_history::EditHistory::new("");
        let _ = neco_pathrel::PathPolicy::posix();
        let _ = neco_filetree::FileTreeNode {
            name: "a".to_string(),
            path: "/a".to_string(),
            kind: neco_filetree::FileTreeNodeKind::File,
            children: Vec::new(),
            materialization: neco_filetree::DirectoryMaterialization::Complete,
            child_count: None,
        };
        let _ = neco_watchnorm::RawWatchKind::Create;
        let _ = neco_tree::Tree::new(0usize);
        let _ = RangeChange::new(0, 0, 0);
    }
}