//! Indexing of words from open buffers.
//!
//! This provides an eventually consistent set of words used in any open buffers. This set is
//! later used for lexical completion.
use std::{borrow::Cow, iter, sync::Arc, time::Duration};
use foldhash::HashMap;
use helix_core::{
chars::char_is_word, diff::compare_ropes, fuzzy::fuzzy_match, ChangeSet, Rope, RopeSlice,
};
use helix_event::{register_hook, AsyncHook, TaskController, TaskHandle};
use helix_stdx::rope::RopeSliceExt as _;
use parking_lot::RwLock;
use tokio::{sync::mpsc, time::Instant};
use crate::{
events::{ConfigDidChange, DocumentDidChange, DocumentDidClose, DocumentDidOpen},
DocumentId,
};
use super::Handlers;
#[derive(Debug)]
struct Change {
old_text: Rope,
text: Rope,
changes: ChangeSet,
/// Whether `changes` is stale and must be recomputed from `old_text`/`text` before use.
///
/// Set when this change is the result of coalescing several observed changes (see
/// [`Hook::handle_event`]). The observed changesets cannot be reliably chained, so the
/// coalesced changeset is recomputed once, lazily, at [`Hook::finish_debounce`].
dirty: bool,
}
#[derive(Debug)]
enum Event {
Insert(Rope),
Update(DocumentId, Change),
Delete(DocumentId, Rope),
/// Clear the entire word index.
/// This is used to clear memory when the feature is turned off.
Clear,
}
/// Sends an event to the coordinator task (lossy).
///
/// The coordinator stops when its [`Handler`] is dropped. A closed channel means that index is no
/// longer in use, so dropping the event is harmless.
fn send(coordinator: &mpsc::UnboundedSender<Event>, event: Event) {
let _ = coordinator.send(event);
}
#[derive(Debug)]
pub struct Handler {
pub(super) index: WordIndex,
/// A sender into an async hook which debounces updates to the index.
hook: mpsc::Sender<Event>,
/// A sender to a tokio task which coordinates the indexing of documents.
///
/// See [WordIndex::run]. A supervisor-like task is in charge of spawning tasks to update the
/// index. This ensures that consecutive edits to a document trigger the correct order of
/// insertions and deletions into the word set.
coordinator: mpsc::UnboundedSender<Event>,
/// Cancels in-flight indexing when the handler is dropped.
///
/// Indexing a large document runs on a blocking task which cannot be preempted. Without this,
/// dropping the tokio runtime on shutdown would block until that task finishes, keeping the
/// process alive and unresponsive. The indexing task holds a [TaskHandle] from this
/// controller and checks it periodically.
_cancel: TaskController,
}
impl Handler {
pub fn spawn() -> Self {
let index = WordIndex::default();
let (tx, rx) = mpsc::unbounded_channel();
let mut cancel = TaskController::new();
tokio::spawn(index.clone().run(rx, cancel.restart()));
Self {
hook: Hook {
changes: HashMap::default(),
coordinator: tx.clone(),
}
.spawn(),
index,
coordinator: tx,
_cancel: cancel,
}
}
}
#[derive(Debug)]
struct Hook {
changes: HashMap<DocumentId, Change>,
coordinator: mpsc::UnboundedSender<Event>,
}
const DEBOUNCE: Duration = Duration::from_secs(1);
impl AsyncHook for Hook {
type Event = Event;
fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant> {
match event {
Event::Insert(_) => unreachable!("inserts are sent to the worker directly"),
Event::Update(doc, change) => {
if let Some(pending_change) = self.changes.get_mut(&doc) {
// There is already a change waiting for this document. Coalesce: keep the
// original `old_text` and advance to the latest `text`.
//
// We deliberately do NOT chain the observed changesets here. The index skips
// ghost transactions (see `register_hooks`), so a ghost edit may have altered
// the document between these two observed changes, which may leave them
// non-contiguous in ways a cheap check can't catch. Instead, mark the change
// dirty and recompute the changeset from the real ropes at `finish_debounce`,
// i.e. once per debounce window rather than once per keystroke.
pending_change.text = change.text;
pending_change.dirty = true;
Some(Instant::now() + DEBOUNCE)
} else if !is_changeset_significant(&change.changes) {
// The change is small: debounce so a burst of edits coalesces before the
// index updates.
self.changes.insert(doc, change);
Some(Instant::now() + DEBOUNCE)
} else {
// The change is large: update the index immediately rather than waiting out
// the debounce.
send(&self.coordinator, Event::Update(doc, change));
timeout
}
}
Event::Delete(doc, text) => {
// If there are pending changes that haven't been indexed since the last debounce,
// forget them and delete the old text.
if let Some(change) = self.changes.remove(&doc) {
send(&self.coordinator, Event::Delete(doc, change.old_text));
} else {
send(&self.coordinator, Event::Delete(doc, text));
}
timeout
}
Event::Clear => unreachable!("clear is sent to the worker directly"),
}
}
fn finish_debounce(&mut self) {
for (doc, mut change) in self.changes.drain() {
// A coalesced change carries a stale changeset; recompute it from the real endpoints.
// This diff is valid regardless of any ghost edits skipped between observed changes.
if change.dirty {
change.changes = compare_ropes(&change.old_text, &change.text)
.changes()
.clone();
change.dirty = false;
}
send(&self.coordinator, Event::Update(doc, change));
}
}
}
/// Minimum number of grapheme clusters required to include a word in the index
const MIN_WORD_GRAPHEMES: usize = 3;
/// Maximum word length allowed (in chars)
const MAX_WORD_LEN: usize = 50;
/// Number of words to index between checks of the cancellation handle.
const CANCEL_CHECK_INTERVAL: usize = 4096;
type Word = kstring::KString;
#[derive(Debug, Default)]
struct WordIndexInner {
/// Reference counted storage for words.
///
/// Words are very likely to be reused many times. Instead of storing duplicates we keep a
/// reference count of times a word is used. When the reference count drops to zero the word
/// is removed from the index.
words: HashMap<Word, u32>,
}
impl WordIndexInner {
fn words(&self) -> impl Iterator<Item = &Word> {
self.words.keys()
}
fn insert(&mut self, word: RopeSlice) {
let word: Cow<str> = word.into();
if let Some(rc) = self.words.get_mut(word.as_ref()) {
*rc = rc.saturating_add(1);
} else {
let word = match word {
Cow::Owned(s) => Word::from_string(s),
Cow::Borrowed(s) => Word::from_ref(s),
};
self.words.insert(word, 1);
}
}
fn remove(&mut self, word: RopeSlice) {
let word: Cow<str> = word.into();
match self.words.get_mut(word.as_ref()) {
Some(1) => {
self.words.remove(word.as_ref());
}
Some(n) => *n -= 1,
None => (),
}
}
fn clear(&mut self) {
std::mem::take(&mut self.words);
}
}
#[derive(Debug, Default, Clone)]
pub struct WordIndex {
inner: Arc<RwLock<WordIndexInner>>,
}
impl WordIndex {
pub fn matches(&self, pattern: &str) -> Vec<String> {
let inner = self.inner.read();
let mut matches = fuzzy_match(pattern, inner.words(), false);
matches.sort_unstable_by_key(|(_, score)| *score);
matches
.into_iter()
.map(|(word, _)| word.to_string())
.collect()
}
fn add_document(&self, text: &Rope, cancel: &TaskHandle) {
let mut inner = self.inner.write();
for (i, word) in words(text.slice(..)).enumerate() {
if i % CANCEL_CHECK_INTERVAL == 0 && cancel.is_canceled() {
return;
}
inner.insert(word);
}
}
fn update_document(
&self,
old_text: &Rope,
text: &Rope,
changes: &ChangeSet,
cancel: &TaskHandle,
) {
let mut inner = self.inner.write();
// A single changed window can span the whole document, so the check is driven by a count
// of the words processed rather than the number of windows.
let mut since_check = 0;
for (old_window, new_window) in changed_windows(old_text.slice(..), text.slice(..), changes)
{
for word in words(new_window) {
inner.insert(word);
since_check += 1;
}
for word in words(old_window) {
inner.remove(word);
since_check += 1;
}
if since_check >= CANCEL_CHECK_INTERVAL {
if cancel.is_canceled() {
return;
}
since_check = 0;
}
}
}
fn remove_document(&self, text: &Rope, cancel: &TaskHandle) {
let mut inner = self.inner.write();
for (i, word) in words(text.slice(..)).enumerate() {
if i % CANCEL_CHECK_INTERVAL == 0 && cancel.is_canceled() {
return;
}
inner.remove(word);
}
}
fn clear(&self) {
let mut inner = self.inner.write();
inner.clear();
}
/// Coordinate the indexing of documents.
///
/// This task wraps a MPSC queue and spawns blocking tasks which update the index. Updates
/// are applied one-by-one to ensure that changes to the index are **serialized**:
/// updates to each document must be applied in-order.
async fn run(self, mut events: mpsc::UnboundedReceiver<Event>, cancel: TaskHandle) {
while let Some(event) = events.recv().await {
if cancel.is_canceled() {
return;
}
let this = self.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || match event {
Event::Insert(text) => {
this.add_document(&text, &cancel);
}
Event::Update(
_doc,
Change {
old_text,
text,
changes,
..
},
) => {
this.update_document(&old_text, &text, &changes, &cancel);
}
Event::Delete(_doc, text) => {
this.remove_document(&text, &cancel);
}
Event::Clear => {
this.clear();
}
})
.await
.unwrap();
}
}
}
/// Extracts indexable words from a rope slice.
///
/// A word is a run of grapheme clusters whose first character is a
/// w[word character][char_is_word], spanning at least [`MIN_WORD_GRAPHEMES`] clusters and at
/// most [`MAX_WORD_LEN`] chars. All other text is skipped.
///
/// This is a single forward pass over the text's grapheme clusters: each cluster is visited once.
/// and the only rope position ever sought is the start of an emitted word, which keeps
/// extraction roughly linear in the length of the text.
fn words(text: RopeSlice) -> impl Iterator<Item = RopeSlice> {
let mut graphemes = text.grapheme_indices();
// The in-progress word run: the byte offset of its first cluster, its length in chars, and the
// number of graphemes it spans. `graphemes_len == 0` means we are between words.
let mut start_byte = 0;
let mut char_len = 0;
let mut graphemes_len = 0;
// Yields `text[start_byte..end_byte]` if that run satisfies the length bounds.
let qualify = move |start_byte, end_byte, char_len, graphemes_len| {
(graphemes_len >= MIN_WORD_GRAPHEMES && char_len <= MAX_WORD_LEN)
.then(|| text.byte_slice(start_byte..end_byte))
};
iter::from_fn(move || {
loop {
let Some((byte_idx, grapheme)) = graphemes.next() else {
// Flush a word that runs up to the end of the text.
let word = qualify(start_byte, text.len_bytes(), char_len, graphemes_len);
graphemes_len = 0;
return word;
};
if grapheme.chars().next().is_some_and(char_is_word) {
if graphemes_len == 0 {
start_byte = byte_idx;
char_len = 0;
}
graphemes_len += 1;
char_len += grapheme.len_chars();
} else if graphemes_len != 0 {
// A non-word cluster ends the current run; `byte_idx` is one past the run's end.
let word = qualify(start_byte, byte_idx, char_len, graphemes_len);
graphemes_len = 0;
if word.is_some() {
return word;
}
}
}
})
}
/// Finds areas of the old and new texts around each operation in `changes`.
///
/// The window is larger than the changed area and can encompass multiple insert/delete operations
/// if they are grouped closely together.
///
/// The ranges of the old and new text should usually be of different sizes. For example a
/// deletion of "foo" surrounded by large retain sections would give a longer window into the
/// `old_text` and shorter window of `new_text`. Vice-versa for an insertion. A full replacement
/// of a word though would give two slices of the same size.
fn changed_windows<'a>(
old_text: RopeSlice<'a>,
new_text: RopeSlice<'a>,
changes: &'a ChangeSet,
) -> impl Iterator<Item = (RopeSlice<'a>, RopeSlice<'a>)> {
use helix_core::Operation::*;
let mut operations = changes.changes().iter().peekable();
let mut old_pos = 0;
let mut new_pos = 0;
iter::from_fn(move || loop {
let operation = operations.next()?;
let old_start = old_pos;
let new_start = new_pos;
let len = operation.len_chars();
match operation {
Retain(_) => {
old_pos += len;
new_pos += len;
continue;
}
Insert(_) => new_pos += len,
Delete(_) => old_pos += len,
}
// Scan ahead until a `Retain` is found which would end a window.
while let Some(o) = operations.next_if(|op| !matches!(op, Retain(n) if *n > MAX_WORD_LEN)) {
let len = o.len_chars();
match o {
Retain(_) => {
old_pos += len;
new_pos += len;
}
Delete(_) => old_pos += len,
Insert(_) => new_pos += len,
}
}
let old_window = old_start.saturating_sub(MAX_WORD_LEN)
..(old_pos + MAX_WORD_LEN).min(old_text.len_chars());
let new_window = new_start.saturating_sub(MAX_WORD_LEN)
..(new_pos + MAX_WORD_LEN).min(new_text.len_chars());
return Some((old_text.slice(old_window), new_text.slice(new_window)));
})
}
/// Estimates whether a changeset is significant or small.
fn is_changeset_significant(changes: &ChangeSet) -> bool {
use helix_core::Operation::*;
let mut diff = 0;
for operation in changes.changes() {
match operation {
Retain(_) => continue,
Delete(_) | Insert(_) => diff += operation.len_chars(),
}
}
// This is arbitrary and could be tuned further:
diff > 1_000
}
pub(crate) fn register_hooks(handlers: &Handlers) {
let coordinator = handlers.word_index.coordinator.clone();
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
let doc = doc!(event.editor, &event.doc);
if doc.word_completion_enabled() {
send(&coordinator, Event::Insert(doc.text().clone()));
}
Ok(())
});
let tx = handlers.word_index.hook.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if !event.ghost_transaction && event.doc.word_completion_enabled() {
helix_event::send_blocking(
&tx,
Event::Update(
event.doc.id(),
Change {
old_text: event.old_text.clone(),
text: event.doc.text().clone(),
changes: event.changes.clone(),
dirty: false,
},
),
);
}
Ok(())
});
let tx = handlers.word_index.hook.clone();
register_hook!(move |event: &mut DocumentDidClose<'_>| {
if event.doc.word_completion_enabled() {
helix_event::send_blocking(
&tx,
Event::Delete(event.doc.id(), event.doc.text().clone()),
);
}
Ok(())
});
let coordinator = handlers.word_index.coordinator.clone();
register_hook!(move |event: &mut ConfigDidChange<'_>| {
// The feature has been turned off. Clear the index and reclaim any used memory.
if event.old.word_completion.enable && !event.new.word_completion.enable {
send(&coordinator, Event::Clear);
}
// The feature has been turned on. Index open documents.
if !event.old.word_completion.enable && event.new.word_completion.enable {
for doc in event.editor.documents() {
if doc.word_completion_enabled() {
send(&coordinator, Event::Insert(doc.text().clone()));
}
}
}
Ok(())
});
}
// See `benches/word_index.rs`.
#[cfg(feature = "bench")]
pub mod bench {
use helix_core::{Rope, RopeSlice};
pub use super::WordIndex;
pub fn add_document(index: &WordIndex, text: &Rope) {
let mut cancel = helix_event::TaskController::new();
index.add_document(text, &cancel.restart());
}
pub fn words(text: RopeSlice) -> impl Iterator<Item = RopeSlice> {
super::words(text)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
use quickcheck::{Arbitrary, Gen};
impl WordIndex {
fn words(&self) -> HashSet<String> {
let inner = self.inner.read();
inner.words().map(|w| w.to_string()).collect()
}
/// The full reference-counted word multiset. Unlike [`WordIndex::words`] this keeps the
/// counts, which the incremental update path must hold exactly in step with a fresh index:
/// a word is only freed once its count falls to zero, so any drift leaks stale words.
fn counts(&self) -> std::collections::HashMap<String, u32> {
let inner = self.inner.read();
inner
.words
.iter()
.map(|(w, c)| (w.to_string(), *c))
.collect()
}
}
#[track_caller]
fn assert_words<I: ToString, T: IntoIterator<Item = I>>(text: &str, expected: T) {
let text = Rope::from_str(text);
let index = WordIndex::default();
let mut cancel = TaskController::new();
index.add_document(&text, &cancel.restart());
let actual = index.words();
let expected: HashSet<_> = expected.into_iter().map(|i| i.to_string()).collect();
assert_eq!(expected, actual);
}
#[test]
fn parse() {
assert_words("one two three", ["one", "two", "three"]);
assert_words("a foo c", ["foo"]);
}
#[track_caller]
fn assert_diff<S, R, I>(before: &str, after: &str, expect_removed: R, expect_inserted: I)
where
S: ToString,
R: IntoIterator<Item = S>,
I: IntoIterator<Item = S>,
{
let before = Rope::from_str(before);
let after = Rope::from_str(after);
let diff = compare_ropes(&before, &after);
let expect_removed: HashSet<_> =
expect_removed.into_iter().map(|i| i.to_string()).collect();
let expect_inserted: HashSet<_> =
expect_inserted.into_iter().map(|i| i.to_string()).collect();
let index = WordIndex::default();
let mut cancel = TaskController::new();
let handle = cancel.restart();
index.add_document(&before, &handle);
let words_before = index.words();
index.update_document(&before, &after, diff.changes(), &handle);
let words_after = index.words();
let actual_removed = words_before.difference(&words_after).cloned().collect();
let actual_inserted = words_after.difference(&words_before).cloned().collect();
eprintln!("\"{before}\" {words_before:?} => \"{after}\" {words_after:?}");
assert_eq!(
expect_removed, actual_removed,
"expected {expect_removed:?} to be removed, instead {actual_removed:?} was"
);
assert_eq!(
expect_inserted, actual_inserted,
"expected {expect_inserted:?} to be inserted, instead {actual_inserted:?} was"
);
}
#[test]
fn diff() {
assert_diff("one two three", "one five three", ["two"], ["five"]);
assert_diff("one two three", "one to three", ["two"], []);
assert_diff("one two three", "one three", ["two"], []);
assert_diff("one two three", "one t{o three", ["two"], []);
assert_diff("one foo three", "one fooo three", ["foo"], ["fooo"]);
}
fn build_change(old: &str, new: &str) -> Change {
let old_text = Rope::from_str(old);
let text = Rope::from_str(new);
let changes = compare_ropes(&old_text, &text).changes().clone();
Change {
old_text,
text,
changes,
dirty: false,
}
}
/// Drives a [`Hook`] through a sequence of observed `(old_text, new_text)` changes to a single
/// document, flushes the debounce, and returns the change the hook hands to the indexer (with
/// its changeset recomputed if the observed changes were coalesced). Returns `None` if no
/// change was emitted.
fn coalesce<'a>(observations: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<Change> {
let (coordinator, mut rx) = mpsc::unbounded_channel();
let mut hook = Hook {
changes: HashMap::default(),
coordinator,
};
let doc = DocumentId::default();
for (old, new) in observations {
hook.handle_event(Event::Update(doc, build_change(old, new)), None);
}
hook.finish_debounce();
match rx.try_recv() {
Ok(Event::Update(_, change)) => Some(change),
_ => None,
}
}
#[track_caller]
fn assert_coalesces(observations: &[(&str, &str)]) {
let change = coalesce(observations.iter().copied())
.expect("a coalesced change should have been emitted");
let first = observations.first().unwrap();
let last = observations.last().unwrap();
// The coalesced change spans from the first text observed to the last.
assert_eq!(change.old_text, Rope::from_str(first.0));
assert_eq!(change.text, Rope::from_str(last.1));
// Its changeset must faithfully map `old_text` -> `text`; `update_document` relies on this
// to locate the windows it reindexes.
let mut applied = change.old_text.clone();
assert!(change.changes.apply(&mut applied));
assert_eq!(applied, change.text);
// Reindexing must converge onto exactly the word multiset of the final text (words and
// refcounts) matching a fresh index built from scratch.
let mut cancel = TaskController::new();
let handle = cancel.restart();
let index = WordIndex::default();
index.add_document(&change.old_text, &handle);
index.update_document(&change.old_text, &change.text, &change.changes, &handle);
let fresh = WordIndex::default();
fresh.add_document(&change.text, &handle);
assert_eq!(index.counts(), fresh.counts());
}
#[test]
fn hook_coalesces_contiguous_changes() {
// Two real edits that chain cleanly: the text after the first edit is the text before the
// second, so the changesets compose directly.
assert_coalesces(&[
("the quick brown fox", "the slowish brown fox"),
("the slowish brown fox", "the slowish green fox"),
]);
}
#[test]
fn hook_coalesces_across_skipped_ghost_edit() {
// A ghost transaction edited the document between the two real edits and was not reported
// to the index, so the text before the second edit no longer matches the text after the
// first. Here it is longer, which would make a chained `ChangeSet::compose` panic on its
// `len_after == len` precondition.
assert_coalesces(&[
("the quick brown fox", "the quick brown foxes"),
("the quick brown foxes jumped over", "the lazy brown foxes"),
]);
}
#[test]
fn hook_coalesces_across_length_preserving_ghost_edit() {
// The subtle case: a ghost edit changes content without changing length, in a region the
// next real edit leaves untouched. The two observed changes then have matching lengths
// but different contents, so a length check alone cannot tell they are non-contiguous.
let p = ".".repeat(60);
assert_coalesces(&[
(&format!("aaa{p}ggg"), &format!("bbb{p}ggg")),
(&format!("bbb{p}hhh"), &format!("ccc{p}hhh")),
]);
}
const CORPORA: &[(&str, &str)] = &[
("arabic", include_str!("../../benches/texts/arabic.txt")),
("english", include_str!("../../benches/texts/english.txt")),
("hindi", include_str!("../../benches/texts/hindi.txt")),
("japanese", include_str!("../../benches/texts/japanese.txt")),
("korean", include_str!("../../benches/texts/korean.txt")),
("mandarin", include_str!("../../benches/texts/mandarin.txt")),
("russian", include_str!("../../benches/texts/russian.txt")),
(
"source_code",
include_str!("../../benches/texts/source_code.txt"),
),
];
#[track_caller]
fn assert_extracts(text: &str, expected: &[&str]) {
let rope = Rope::from_str(text);
let got: Vec<String> = words(rope.slice(..)).map(|w| w.to_string()).collect();
assert_eq!(got, expected, "extracting words from {text:?}");
}
/// `words` categorizes whole grapheme clusters, so a word boundary never falls inside a
/// cluster. A non-word combining mark stays attached to the word character it modifies.
#[test]
fn extract_respects_grapheme_clusters() {
// Whitespace and punctuation separate words. Runs under MIN_WORD_GRAPHEMES are dropped.
assert_extracts("a foo c", &["foo"]);
assert_extracts(
"foo.bar.baz qux::quux",
&["foo", "bar", "baz", "qux", "quux"],
);
assert_extracts("snake_case_id CamelCase", &["snake_case_id", "CamelCase"]);
// Precomposed and decomposed accents both keep the whole word: the combining mark rides
// along with the letter it attaches to rather than truncating the word before it.
assert_extracts("naïve café résumé", &["naïve", "café", "résumé"]);
assert_extracts(
"nai\u{0308}ve cafe\u{0301}",
&["nai\u{0308}ve", "cafe\u{0301}"],
);
// A Devanagari conjunct is joined by a virama (itself a non-word char) yet stays one word.
assert_extracts("ज्ञानकोश है", &["ज्ञानकोश"]);
// Hangul syllables are word characters.
assert_extracts("한국어 위키백과", &["한국어", "위키백과"]);
// An emoji cluster is not.
assert_extracts("emoji 👍🏽 word", &["emoji", "word"]);
}
/// Every word extracted from the corpora must satisfy the documented invariants.
#[test]
fn extract_corpora_invariants() {
for (name, text) in CORPORA {
let rope = Rope::from_str(text);
let mut count = 0;
for word in words(rope.slice(..)) {
count += 1;
let clusters = word.graphemes().count();
assert!(
clusters >= MIN_WORD_GRAPHEMES,
"{name}: {word:?} spans only {clusters} grapheme cluster(s)"
);
assert!(
word.len_chars() <= MAX_WORD_LEN,
"{name}: {word:?} is {} chars long",
word.len_chars()
);
assert!(
word.chars().next().is_some_and(char_is_word),
"{name}: {word:?} starts with a non-word character"
);
}
assert!(count > 0, "{name}: expected to extract some words");
}
}
/// Grapheme-cluster edge cases which the prose corpora underrepresent: combining marks at
/// word starts and seams, a skin-tone emoji, a ZWJ family sequence, a Devanagari conjunct,
/// and a lone Hangul syllable.
const SPICE: &[&str] = &[
"a\u{0301}", // base + combining acute
"\u{0301}lead", // leading combining mark
"mid\u{0308}dle", // combining diaeresis mid-word
"👍🏽", // emoji + skin-tone modifier
"👨\u{200D}👩\u{200D}👧", // ZWJ family sequence
"क्ष", // Devanagari conjunct (virama)
"한", // Hangul syllable
];
/// Picks a chunk of text: usually a random char span sliced out of one of the multilingual
/// corpora (real grapheme complexity for free, and slicing on char boundaries deliberately
/// frays some clusters), occasionally an adversarial cluster from [`SPICE`].
fn sample_text(g: &mut Gen) -> String {
if usize::arbitrary(g) % 4 == 0 {
return g.choose(SPICE).unwrap().to_string();
}
let (_, corpus) = g.choose(CORPORA).unwrap();
let chars: Vec<char> = corpus.chars().collect();
if chars.is_empty() {
return String::new();
}
let span = usize::arbitrary(g) % 200;
let start = usize::arbitrary(g) % chars.len();
let end = (start + span).min(chars.len());
chars[start..end].iter().collect()
}
/// Applies one random splice to `text`: delete a random char range and insert a fresh chunk.
fn random_edit(g: &mut Gen, text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let del_start = usize::arbitrary(g) % (len + 1);
let del_len = usize::arbitrary(g) % (len - del_start + 1);
let mut out: String = chars[..del_start].iter().collect();
out.push_str(&sample_text(g));
out.extend(&chars[del_start + del_len..]);
out
}
/// An obvious, independent reimplementation of [`words`]: walk grapheme clusters, accumulate
/// a run of consecutive word-character clusters, and emit it when it ends if it satisfies the
/// length bounds. Used as an oracle for the optimized single-pass extractor.
fn reference_words(text: &str) -> Vec<String> {
let rope = Rope::from_str(text);
let mut out = Vec::new();
let mut run = String::new();
let mut clusters = 0usize;
let flush = |run: &mut String, clusters: &mut usize, out: &mut Vec<String>| {
if *clusters >= MIN_WORD_GRAPHEMES && run.chars().count() <= MAX_WORD_LEN {
out.push(run.clone());
}
run.clear();
*clusters = 0;
};
for cluster in rope.slice(..).graphemes() {
if cluster.chars().next().is_some_and(char_is_word) {
run.extend(cluster.chars());
clusters += 1;
} else {
flush(&mut run, &mut clusters, &mut out);
}
}
flush(&mut run, &mut clusters, &mut out);
out
}
#[derive(Clone, Debug)]
struct SampledText(String);
impl Arbitrary for SampledText {
fn arbitrary(g: &mut Gen) -> Self {
SampledText(sample_text(g))
}
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
Box::new(self.0.shrink().map(SampledText))
}
}
#[derive(Clone, Debug)]
struct EditPair {
old: String,
new: String,
}
impl Arbitrary for EditPair {
fn arbitrary(g: &mut Gen) -> Self {
let old = sample_text(g);
let new = random_edit(g, &old);
EditPair { old, new }
}
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
Box::new(
(self.old.clone(), self.new.clone())
.shrink()
.map(|(old, new)| EditPair { old, new }),
)
}
}
/// A sequence of `(old_text, new_text)` changes as the hook observes them. Some steps leave
/// a gap (the next change's `old_text` differs from the previous `new_text`) modelling an
/// unobserved ghost edit applied to the document between two real ones.
#[derive(Clone, Debug)]
struct Observations(Vec<(String, String)>);
impl Arbitrary for Observations {
fn arbitrary(g: &mut Gen) -> Self {
let steps = usize::arbitrary(g) % 5 + 1;
let mut current = sample_text(g);
let mut observations = Vec::with_capacity(steps);
for _ in 0..steps {
let observed_old = if bool::arbitrary(g) {
// A ghost edit perturbed the document before this real edit, so the observed
// `old_text` no longer matches the previous `new_text`.
random_edit(g, ¤t)
} else {
current.clone()
};
let new = random_edit(g, &observed_old);
observations.push((observed_old, new.clone()));
current = new;
}
Observations(observations)
}
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
Box::new(self.0.shrink().map(Observations))
}
}
quickcheck::quickcheck! {
/// The optimized single-pass extractor agrees with the obvious reference on arbitrary
/// (multilingual, cluster-frayed) text.
fn prop_words_match_reference(text: SampledText) -> bool {
let rope = Rope::from_str(&text.0);
let got: Vec<String> = words(rope.slice(..)).map(|w| w.to_string()).collect();
got == reference_words(&text.0)
}
/// Incrementally updating across one edit lands on exactly the same word multiset (words
/// and refcounts) as reindexing the new text from scratch.
fn prop_update_document_matches_fresh(pair: EditPair) -> bool {
let old = Rope::from_str(&pair.old);
let new = Rope::from_str(&pair.new);
let changes = compare_ropes(&old, &new).changes().clone();
let mut cancel = TaskController::new();
let handle = cancel.restart();
let incremental = WordIndex::default();
incremental.add_document(&old, &handle);
incremental.update_document(&old, &new, &changes, &handle);
let fresh = WordIndex::default();
fresh.add_document(&new, &handle);
incremental.counts() == fresh.counts()
}
/// Coalescing a sequence of observed changes (gaps and all) and flushing yields a change
/// that maps its `old_text` to its `text` and drives the index to the same multiset as a
/// fresh index of the final text.
fn prop_hook_coalescing_matches_fresh(obs: Observations) -> bool {
let Some(change) = coalesce(obs.0.iter().map(|(o, n)| (o.as_str(), n.as_str()))) else {
return true;
};
let mut applied = change.old_text.clone();
if !change.changes.apply(&mut applied) || applied != change.text {
return false;
}
let mut cancel = TaskController::new();
let handle = cancel.restart();
let incremental = WordIndex::default();
incremental.add_document(&change.old_text, &handle);
incremental.update_document(&change.old_text, &change.text, &change.changes, &handle);
let fresh = WordIndex::default();
fresh.add_document(&change.text, &handle);
incremental.counts() == fresh.counts()
}
}
}