//! Property tests against a naive `Vec` model of the document. use ide_core::{ChangeSet, Pos, Range, TextChange, TextEdit}; use ide_text::{AppliedChange, ByteEdit, BytePoint, Document}; use proptest::prelude::*; const ALPHABET: &[char] = &['a', 'b', ' ', '\n', 'é', '中', '😀', '\t']; fn text_strategy(max: usize) -> impl Strategy { prop::collection::vec(prop::sample::select(ALPHABET), 0..max).prop_map(|cs| cs.into_iter().collect()) } fn utf16(s: &str) -> Vec { s.encode_utf16().collect() } /// UTF-16 offsets of every char boundary, including the end. fn boundaries(s: &str) -> Vec { let mut out = vec![0]; let mut acc = 0u32; for c in s.chars() { acc += c.len_utf16() as u32; out.push(acc); } out } fn naive_apply(s: &str, cs: &ChangeSet) -> String { let src = utf16(s); let mut out = Vec::new(); let mut last = 0usize; for c in &cs.changes { out.extend_from_slice(&src[last..c.from as usize]); out.extend(c.insert.encode_utf16()); last = c.to as usize; } out.extend_from_slice(&src[last..]); String::from_utf16(&out).unwrap() } fn naive_pos_to_offset(s: &str, pos: Pos) -> usize { let mut off = 0usize; for (i, line) in s.split('\n').enumerate() { if i == pos.line as usize { return off + (pos.col as usize).min(line.encode_utf16().count()); } off += line.encode_utf16().count() + 1; } s.encode_utf16().count() } fn naive_offset_to_pos(s: &str, offset: usize) -> Pos { let prefix = String::from_utf16(&utf16(s)[..offset]).unwrap(); let line = prefix.matches('\n').count() as u32; let col = prefix.rsplit('\n').next().unwrap().encode_utf16().count() as u32; Pos::new(line, col) } fn naive_replay(s: &str, applied: &[AppliedChange]) -> String { let mut cur = s.to_owned(); for a in applied { let from = naive_pos_to_offset(&cur, a.range.start); let to = naive_pos_to_offset(&cur, a.range.end); let mut v = utf16(&cur); v.splice(from..to, a.text.encode_utf16()); cur = String::from_utf16(&v).unwrap(); } cur } /// Builds a valid sorted, non-overlapping change set from random picks. fn build_change_set(doc: &str, picks: &[(usize, usize, String)]) -> ChangeSet { let b = boundaries(doc); let mut points: Vec = picks.iter().flat_map(|(x, y, _)| [b[x % b.len()], b[y % b.len()]]).collect(); points.sort_unstable(); let changes = points .chunks(2) .zip(picks) .map(|(pair, (_, _, insert))| TextChange { from: pair[0], to: pair[1], insert: insert.clone() }) .collect(); ChangeSet { changes } } fn byte_point(s: &str, byte: usize) -> BytePoint { let prefix = &s[..byte]; let row = prefix.matches('\n').count(); let col = byte - prefix.rfind('\n').map_or(0, |i| i + 1); BytePoint { row, col } } /// Replays byte edits on `s` (as tree-sitter would), checking every point. fn replay_bytes(s: &str, edits: &[ByteEdit], applied: &[AppliedChange]) -> Result { prop_assert_eq!(edits.len(), applied.len()); let mut cur = s.to_owned(); for (e, a) in edits.iter().zip(applied) { prop_assert_eq!(e.start, byte_point(&cur, e.start_byte)); prop_assert_eq!(e.old_end, byte_point(&cur, e.old_end_byte)); prop_assert_eq!(e.new_end_byte - e.start_byte, a.text.len()); cur.replace_range(e.start_byte..e.old_end_byte, &a.text); prop_assert_eq!(e.new_end, byte_point(&cur, e.new_end_byte)); } Ok(cur) } fn picks_strategy() -> impl Strategy> { prop::collection::vec((any::(), any::(), text_strategy(6)), 0..8) } proptest! { #![proptest_config(ProptestConfig::with_cases(512))] #[test] fn change_set_matches_naive(doc_text in text_strategy(60), picks in picks_strategy()) { let cs = build_change_set(&doc_text, &picks); let mut doc = Document::new(&doc_text); let applied = doc.apply(0, &cs).unwrap(); let expected = naive_apply(&doc_text, &cs); prop_assert_eq!(doc.text(), expected.clone()); prop_assert_eq!(doc.version(), 1); prop_assert_eq!(naive_replay(&doc_text, &applied), expected); } #[test] fn byte_edits_replay(doc_text in text_strategy(60), picks in picks_strategy()) { let cs = build_change_set(&doc_text, &picks); let mut doc = Document::new(&doc_text); let (applied, edits) = doc.apply_detailed(0, &cs).unwrap(); prop_assert_eq!(replay_bytes(&doc_text, &edits, &applied)?, doc.text()); } #[test] fn apply_edits_matches_naive(doc_text in text_strategy(60), picks in picks_strategy(), seed in any::()) { let cs = build_change_set(&doc_text, &picks); let edits: Vec = cs .changes .iter() .map(|c| TextEdit { range: Range::new( naive_offset_to_pos(&doc_text, c.from as usize), naive_offset_to_pos(&doc_text, c.to as usize), ), new_text: c.insert.clone(), }) .collect(); // Shuffle, then restore the relative order of edits with identical ranges // (same-position inserts), whose order is significant. let mut order: Vec = (0..edits.len()).collect(); let mut state = seed | 1; for i in (1..order.len()).rev() { state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); order.swap(i, (state >> 33) as usize % (i + 1)); } let mut groups: std::collections::HashMap> = Default::default(); for (slot, &orig) in order.iter().enumerate() { groups.entry(edits[orig].range).or_default().push(slot); } for slots in groups.values() { let mut originals: Vec = slots.iter().map(|&k| order[k]).collect(); originals.sort_unstable(); for (&k, orig) in slots.iter().zip(originals) { order[k] = orig; } } let edits: Vec = order.iter().map(|&i| edits[i].clone()).collect(); let mut doc = Document::new(&doc_text); let (set, applied) = doc.apply_edits(&edits).unwrap(); let expected = naive_apply(&doc_text, &cs); prop_assert_eq!(doc.text(), expected.clone()); prop_assert_eq!(naive_apply(&doc_text, &set), expected.clone()); prop_assert_eq!(naive_replay(&doc_text, &applied), expected); } #[test] fn replace_all_matches(a in text_strategy(40), b in text_strategy(40)) { let mut doc = Document::new(&a); let set = doc.replace_all(&b); prop_assert_eq!(doc.text(), b.clone()); prop_assert_eq!(naive_apply(&a, &set), b.clone()); prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from)); prop_assert_eq!(set.changes.is_empty(), a == b); } #[test] fn coordinate_round_trips(text in text_strategy(80)) { let doc = Document::new(&text); prop_assert_eq!(doc.len_utf16() as usize, text.encode_utf16().count()); prop_assert_eq!(doc.line_count() as usize, text.split('\n').count()); for off in boundaries(&text) { let pos = doc.offset_to_pos(off); prop_assert_eq!(pos, naive_offset_to_pos(&text, off as usize)); prop_assert_eq!(doc.pos_to_offset(pos), off); let byte = doc.pos_to_byte(pos); prop_assert!(text.is_char_boundary(byte)); prop_assert_eq!(doc.byte_to_pos(byte), pos); prop_assert_eq!(doc.offset_to_byte(off), byte); prop_assert_eq!(doc.byte_to_offset(byte), off); } for (i, line) in text.split('\n').enumerate() { prop_assert_eq!(doc.line(i as u32).unwrap(), line); } } #[test] fn crlf_round_trip(lines in prop::collection::vec(text_strategy(10).prop_map(|s| s.replace('\n', "")), 1..8)) { let original = lines.join("\r\n"); let doc = Document::new(&original); prop_assert_eq!(doc.text(), lines.join("\n")); if lines.len() > 1 { prop_assert_eq!(doc.text_for_save(), original); } } } /// Guards against quadratic paths on huge single-line documents (minified /// bundles): edits and conversions far into a 10 MB line stay fast. #[test] fn long_lines_stay_fast() { let line: String = "abcdefé😀".repeat(1_000_000); let mut doc = Document::new(&line); let len = doc.len_utf16(); let start = std::time::Instant::now(); for i in 0..2_000u32 { let at = (i.wrapping_mul(2_654_435_761) % len) & !1; let pos = doc.offset_to_pos(at); let off = doc.pos_to_offset(pos); let byte = doc.offset_to_byte(off); assert_eq!(doc.byte_to_offset(byte), off); let at = doc.pos_to_offset(doc.offset_to_pos(at)); let cs = ChangeSet { changes: vec![TextChange { from: at, to: at, insert: "x\n".into() }] }; let (_, edits) = doc.apply_detailed(doc.version(), &cs).unwrap(); assert_eq!((edits[0].new_end.row, edits[0].new_end.col), (edits[0].start.row + 1, 0)); } let elapsed = start.elapsed(); assert_eq!(doc.line_count(), 2_001); // Generous bound (debug build, slow CI); a quadratic path takes minutes. assert!(elapsed < std::time::Duration::from_secs(20), "took {elapsed:?}"); }