vimlet 0.2.0

A headless Vim editing engine for Rust
Documentation
use std::cell::Cell;
use unicode_segmentation::UnicodeSegmentation;
use vimlet::*;

struct Chunks {
    text: String,
    starts: Vec<usize>,
}

impl Chunks {
    fn new(text: &str) -> Self {
        Self {
            text: text.into(),
            starts: text.char_indices().map(|(p, _)| p).collect(),
        }
    }
}

impl TextSource for Chunks {
    fn len_bytes(&self) -> usize {
        self.text.len()
    }

    fn chunk_at(&self, byte: usize) -> (&str, usize) {
        if self.text.is_empty() {
            return ("", 0);
        }
        let i = self
            .starts
            .partition_point(|p| *p <= byte.min(self.text.len() - 1))
            .saturating_sub(1);
        let start = self.starts[i];
        let end = self.starts.get(i + 1).copied().unwrap_or(self.text.len());
        (&self.text[start..end], start)
    }
}

fn check_source(source: &dyn TextSource, text: &str) {
    let boundaries: Vec<_> = text
        .grapheme_indices(true)
        .map(|(p, _)| p)
        .chain(std::iter::once(text.len()))
        .collect();
    for pair in boundaries.windows(2) {
        assert_eq!(
            source.next_grapheme(pair[0]),
            pair[1],
            "next at {} in {text:?}",
            pair[0]
        );
        assert_eq!(
            source.prev_grapheme(pair[1]),
            pair[0],
            "prev at {} in {text:?}",
            pair[1]
        );
    }
    for (scalar, (byte, _)) in text.char_indices().enumerate() {
        assert_eq!(source.scalar_to_byte(scalar), byte);
        assert_eq!(source.byte_to_scalar(byte), scalar);
    }
    assert_eq!(source.slice(0, source.len_bytes()), text);
}

#[test]
fn graphemes_cross_every_possible_scalar_chunk_boundary() {
    for text in [
        "",
        "abc",
        "a👩‍💻z",
        "x🇧🇷🇦🇷y",
        "aहिz",
        "e\u{301}\u{302}x",
        "a\r\nb\nc",
        "\u{600}a",
        "각x",
    ] {
        check_source(&text, text);
        check_source(&Chunks::new(text), text);
    }
}

#[cfg(feature = "ropey")]
#[test]
fn rope_chunks_and_strings_agree_on_unicode_and_lines() {
    for text in [
        "a👩‍💻z\r\nb\n".repeat(1000),
        format!("{}e\u{301}{}", "x".repeat(1023), "z".repeat(1000)),
        "a\rb\u{85}c\u{2028}d\n".into(),
    ] {
        let rope = ropey::Rope::from_str(&text);
        check_source(&rope, &text);
        for (p, _) in text.char_indices() {
            assert_eq!(
                TextSource::line_index(&rope, p),
                TextSource::line_index(&text, p)
            );
            assert_eq!(TextSource::line_start(&rope, p), text.line_start(p));
            assert_eq!(TextSource::line_end(&rope, p), text.line_end(p));
        }
    }
}

#[test]
fn whitespace_objects_and_counts_are_adapter_independent() {
    let text = "one  two three";
    let chunked = Chunks::new(text);
    for source in [&text as &dyn TextSource, &chunked] {
        let resolver = MotionResolver::new(source, ByteOffset(3));
        let inner = resolver
            .text_object(TextObjectScope::Inner, TextObjectKind::Word, 1)
            .unwrap();
        assert_eq!(
            source.slice(inner.ranges[0].start.0, inner.ranges[0].end.0),
            "  "
        );
        let around = resolver
            .text_object(TextObjectScope::Around, TextObjectKind::Word, 1)
            .unwrap();
        assert_eq!(
            source.slice(around.ranges[0].start.0, around.ranges[0].end.0),
            "  two"
        );
    }
}

#[test]
fn opaque_elements_are_not_searchable_delimiters_or_find_targets() {
    let text = "a[not(a)word] (real)z";
    let spans = [TextRange::new(1, 13)];
    let view = DocumentView {
        text: &text,
        cursor: ByteOffset(0),
        version: DocumentVersion::default(),
        selection: None,
        opaque: &spans,
        geometry: &DEFAULT_GEOMETRY,
    };
    let resolver = MotionResolver::from_view(view);
    assert_eq!(
        resolver
            .resolve(
                Motion::Character {
                    kind: CharacterMotion::Find,
                    character: 'a'
                },
                1
            )
            .unwrap()
            .position,
        ByteOffset(17)
    );
    assert_eq!(
        resolver
            .resolve(Motion::MatchDelimiter, 1)
            .unwrap()
            .position,
        ByteOffset(19)
    );
    assert_eq!(resolver.next(1), 13);
    assert_eq!(resolver.previous(13), 1);
    assert_eq!(resolver.expand_opaque(TextRange::new(2, 5)), spans[0]);
}

#[test]
fn invalid_utf8_coordinates_are_rejected() {
    let text = "éx";
    let view = DocumentView::new(&text, DocumentVersion::default(), ByteOffset(1));
    assert!(!view.is_valid());
}

struct Counted<'a> {
    text: &'a str,
    reads: Cell<usize>,
}

impl TextSource for Counted<'_> {
    fn len_bytes(&self) -> usize {
        self.text.len()
    }

    fn chunk_at(&self, _: usize) -> (&str, usize) {
        self.reads.set(self.reads.get() + 1);
        (self.text, 0)
    }

    fn line_start(&self, _: usize) -> usize {
        panic!("local operation scanned to the start of the line");
    }

    fn line_end(&self, _: usize) -> usize {
        panic!("local operation scanned to the end of the line");
    }

    fn slice(&self, start: usize, end: usize) -> std::borrow::Cow<'_, str> {
        assert!(end - start < 100, "local motion flattened text");
        std::borrow::Cow::Borrowed(&self.text[start..end])
    }
}

#[test]
fn local_motion_does_not_materialize_a_document() {
    let text = "a".repeat(3 * 1024 * 1024);
    let source = Counted {
        text: &text,
        reads: Cell::new(0),
    };
    let mut p = text.len() / 2;
    for _ in 0..100 {
        let resolver = MotionResolver::new(&source, ByteOffset(p));
        p = resolver.resolve(Motion::Left, 1).unwrap().position.0;
    }
    assert!(source.reads.get() < 1000);
}

#[test]
fn local_character_edits_do_not_scan_the_line() {
    let source = Counted {
        text: "abcd",
        reads: Cell::new(0),
    };
    let view = DocumentView::new(&source, DocumentVersion::default(), ByteOffset(2));
    for keys in ["x", "X", "rz", "a"] {
        let mut state = EditingState::default();
        let mut session = Session::new();
        let mut step = Step::Unhandled;
        for key in keys.chars() {
            step = session.handle(&mut state, Input::key(Key::Char(key)), view);
        }
        assert!(matches!(step, Step::HostOperation(_)), "{keys}");
    }
}