1use std::collections::HashSet;
8
9use regex::RegexBuilder;
10use serde::Serialize;
11
12use super::types::{Document, StructuralElement};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16#[serde(tag = "reason", rename_all = "kebab-case")]
17pub enum AnchorError {
18 InvalidAnchor,
20 InvalidInsertionText,
22 InvalidSelection,
24 SegmentNotFound,
26 AmbiguousSegment,
28 NotFound,
30 Ambiguous {
32 count: usize,
34 },
35 InvalidIndices,
37 SuggestedContent,
39 UnsafeRange,
41 UnorderedRange,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct SegmentSelection {
48 pub segment_id: Option<String>,
50 pub tab_id: Option<String>,
52}
53
54impl SegmentSelection {
55 pub fn validate(&self) -> Result<(), AnchorError> {
57 if self.segment_id.as_ref().is_some_and(String::is_empty)
58 || self.tab_id.as_ref().is_some_and(String::is_empty)
59 {
60 return Err(AnchorError::InvalidSelection);
61 }
62 if self.tab_id.is_some() && self.segment_id.is_none() {
63 return Err(AnchorError::InvalidSelection);
64 }
65 Ok(())
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum SegmentKind {
73 Header,
75 Footer,
77 Footnote,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Side {
84 Before,
86 After,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct EditPreview {
93 pub start_index: i64,
95 pub end_index: i64,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub tab_id: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub segment_id: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub segment_kind: Option<SegmentKind>,
106 pub paragraphs: usize,
108 pub chars: usize,
110 pub bytes: usize,
112}
113
114struct Run<'a> {
115 text: &'a str,
116 start: i64,
117 end: i64,
118 suggested: bool,
119}
120
121struct Paragraph<'a> {
122 tab: usize,
123 tab_id: Option<String>,
124 container: usize,
125 start: i64,
126 end: i64,
127 protected_newline: bool,
129 runs: Vec<Run<'a>>,
130}
131
132#[derive(Clone, Copy)]
133struct Match {
134 paragraph: usize,
135 start: i64,
136 end: i64,
137 suggested: bool,
138}
139
140fn utf16_len(text: &str) -> Result<i64, AnchorError> {
141 i64::try_from(text.encode_utf16().count()).map_err(|_| AnchorError::InvalidIndices)
142}
143
144fn paragraphs<'a>(
145 document: &'a Document,
146 selection: &SegmentSelection,
147) -> Result<(Vec<Paragraph<'a>>, Option<SegmentKind>), AnchorError> {
148 selection.validate()?;
149 let mut out = Vec::new();
150 let mut selected = Vec::new();
151 let mut container = 0;
152 let mut tab_ids = HashSet::new();
153 for (tab, resolved) in document.resolved_tabs().iter().enumerate() {
154 if !document.tabs.is_empty() {
155 let id = resolved
156 .tab_id
157 .filter(|id| !id.is_empty())
158 .ok_or(AnchorError::InvalidIndices)?;
159 if !tab_ids.insert(id) {
160 return Err(AnchorError::InvalidIndices);
161 }
162 }
163 if let Some(id) = &selection.segment_id {
164 if selection
165 .tab_id
166 .as_deref()
167 .is_some_and(|tab| Some(tab) != resolved.tab_id)
168 {
169 continue;
170 }
171 for (kind, segments) in [
172 (SegmentKind::Header, resolved.headers()),
173 (SegmentKind::Footer, resolved.footers()),
174 (SegmentKind::Footnote, resolved.footnotes()),
175 ] {
176 for segment in segments {
177 if segment.segment_id == id {
178 selected.push((tab, resolved.tab_id, kind, segment));
179 }
180 }
181 }
182 } else if let Some(body) = resolved.body {
183 collect(
184 &body.content,
185 tab,
186 resolved.tab_id,
187 false,
188 &mut container,
189 &mut out,
190 )?;
191 }
192 }
193 let mut segment_kind = None;
194 if selection.segment_id.is_some() {
195 let (tab, tab_id, kind, segment) = match selected.as_slice() {
196 [] => return Err(AnchorError::SegmentNotFound),
197 [one] => *one,
198 _ => return Err(AnchorError::AmbiguousSegment),
199 };
200 segment_kind = Some(kind);
201 collect(segment.content, tab, tab_id, true, &mut container, &mut out)?;
202 }
203 Ok((out, segment_kind))
204}
205
206fn collect<'a>(
207 elements: &'a [StructuralElement],
208 tab: usize,
209 tab_id: Option<&str>,
210 segment: bool,
211 next_container: &mut usize,
212 out: &mut Vec<Paragraph<'a>>,
213) -> Result<(), AnchorError> {
214 let container = *next_container;
215 *next_container += 1;
216 for (position, element) in elements.iter().enumerate() {
217 if let Some(paragraph) = &element.paragraph {
218 let start = if segment {
219 element.start_index()
220 } else {
221 element.start_index.ok_or(AnchorError::InvalidIndices)?
222 };
223 let end = element.end_index.ok_or(AnchorError::InvalidIndices)?;
224 if start < i64::from(!segment) || end <= start {
225 return Err(AnchorError::InvalidIndices);
226 }
227 let mut runs = Vec::new();
228 let mut previous_end = start;
229 for inline in ¶graph.elements {
230 let run_start = if segment {
231 inline.start_index.unwrap_or(0)
232 } else {
233 inline.start_index.ok_or(AnchorError::InvalidIndices)?
234 };
235 let run_end = inline.end_index.ok_or(AnchorError::InvalidIndices)?;
236 if run_start < previous_end || run_end <= run_start || run_end > end {
237 return Err(AnchorError::InvalidIndices);
238 }
239 previous_end = run_end;
240 if let Some(text) = &inline.text_run {
241 if run_start.checked_add(utf16_len(&text.content)?) != Some(run_end) {
242 return Err(AnchorError::InvalidIndices);
243 }
244 runs.push(Run {
245 text: &text.content,
246 start: run_start,
247 end: run_end,
248 suggested: !text.suggested_insertion_ids.is_empty()
249 || !text.suggested_deletion_ids.is_empty(),
250 });
251 }
252 }
253 if !runs
254 .last()
255 .is_some_and(|run| run.end == end && run.text.ends_with('\n'))
256 {
257 return Err(AnchorError::InvalidIndices);
258 }
259 let protected_newline = elements
260 .get(position + 1)
261 .is_none_or(|next| next.paragraph.is_none());
262 out.push(Paragraph {
263 tab,
264 tab_id: tab_id.map(str::to_owned),
265 container,
266 start,
267 end,
268 protected_newline,
269 runs,
270 });
271 } else if let Some(table) = &element.table {
272 for row in &table.table_rows {
273 for cell in &row.table_cells {
274 collect(&cell.content, tab, tab_id, segment, next_container, out)?;
275 }
276 }
277 }
278 }
280 Ok(())
281}
282
283fn find(
284 paragraphs: &[Paragraph<'_>],
285 needle: &str,
286 match_case: bool,
287) -> Result<Match, AnchorError> {
288 if needle.is_empty() || needle.contains(['\n', '\r']) {
289 return Err(AnchorError::InvalidAnchor);
290 }
291 let pattern = RegexBuilder::new(®ex::escape(needle))
292 .case_insensitive(!match_case)
293 .build()
294 .map_err(|_| AnchorError::InvalidAnchor)?;
295 let mut count = 0;
296 let mut unique = None;
297 for (paragraph, p) in paragraphs.iter().enumerate() {
298 let mut first = 0;
301 while first < p.runs.len() {
302 let mut last = first + 1;
303 while last < p.runs.len() && p.runs[last - 1].end == p.runs[last].start {
304 last += 1;
305 }
306 let runs = &p.runs[first..last];
307 let text: String = runs.iter().map(|run| run.text).collect();
308 let mut offset = 0;
309 while let Some(found) = pattern.find_at(&text, offset) {
310 let start = map_boundary(runs, found.start())?;
311 let end = map_boundary(runs, found.end())?;
312 let suggested = runs
313 .iter()
314 .any(|run| run.suggested && run.start < end && run.end > start);
315 count += 1;
316 unique = Some(Match {
317 paragraph,
318 start,
319 end,
320 suggested,
321 });
322 offset = found.start()
324 + text[found.start()..]
325 .chars()
326 .next()
327 .ok_or(AnchorError::InvalidAnchor)?
328 .len_utf8();
329 }
330 first = last;
331 }
332 }
333 match count {
334 0 => Err(AnchorError::NotFound),
335 1 => unique.ok_or(AnchorError::NotFound),
336 count => Err(AnchorError::Ambiguous { count }),
337 }
338}
339
340fn map_boundary(runs: &[Run<'_>], mut byte: usize) -> Result<i64, AnchorError> {
341 for run in runs {
342 if byte <= run.text.len() {
343 let prefix = run.text.get(..byte).ok_or(AnchorError::InvalidIndices)?;
344 return run
345 .start
346 .checked_add(utf16_len(prefix)?)
347 .ok_or(AnchorError::InvalidIndices);
348 }
349 byte -= run.text.len();
350 }
351 Err(AnchorError::InvalidIndices) }
353
354pub(crate) fn insertion_counts(text: &str) -> (usize, usize) {
358 text.chars().filter(|ch| !matches!(ch, '\u{0000}'..='\u{0008}' | '\u{000c}'..='\u{001f}' | '\u{e000}'..='\u{f8ff}'))
359 .fold((0, 0), |(chars, bytes), ch| (chars + 1, bytes + ch.len_utf8()))
360}
361
362pub fn resolve_insert(
364 document: &Document,
365 anchor: &str,
366 side: Side,
367 text: &str,
368 match_case: bool,
369) -> Result<EditPreview, AnchorError> {
370 resolve_insert_in(
371 document,
372 anchor,
373 side,
374 text,
375 match_case,
376 &SegmentSelection::default(),
377 )
378}
379
380pub fn resolve_insert_in(
382 document: &Document,
383 anchor: &str,
384 side: Side,
385 text: &str,
386 match_case: bool,
387 selection: &SegmentSelection,
388) -> Result<EditPreview, AnchorError> {
389 let (chars, bytes) = insertion_counts(text);
390 if chars == 0 {
391 return Err(AnchorError::InvalidInsertionText);
392 }
393 let (paragraphs, segment_kind) = paragraphs(document, selection)?;
394 let found = find(¶graphs, anchor, match_case)?;
395 if found.suggested {
396 return Err(AnchorError::SuggestedContent);
397 }
398 let p = ¶graphs[found.paragraph];
399 let index = match side {
400 Side::Before => found.start,
401 Side::After => found.end,
402 };
403 if index < p.start || index >= p.end {
404 return Err(AnchorError::UnsafeRange); }
406 Ok(EditPreview {
407 start_index: index,
408 end_index: index,
409 tab_id: p.tab_id.clone(),
410 segment_id: selection.segment_id.clone(),
411 segment_kind,
412 paragraphs: 1,
413 chars,
414 bytes,
415 })
416}
417
418pub fn resolve_delete(
423 document: &Document,
424 from: &str,
425 to: Option<&str>,
426 match_case: bool,
427) -> Result<EditPreview, AnchorError> {
428 resolve_delete_in(document, from, to, match_case, &SegmentSelection::default())
429}
430
431pub fn resolve_delete_in(
433 document: &Document,
434 from: &str,
435 to: Option<&str>,
436 match_case: bool,
437 selection: &SegmentSelection,
438) -> Result<EditPreview, AnchorError> {
439 let (paragraphs, segment_kind) = paragraphs(document, selection)?;
440 let first = find(¶graphs, from, match_case)?;
441 let last = if let Some(to) = to {
442 find(¶graphs, to, match_case)?
443 } else {
444 first
445 };
446 let a = ¶graphs[first.paragraph];
447 let b = ¶graphs[last.paragraph];
448 if a.tab != b.tab
449 || a.container != b.container
450 || first.start > last.start
451 || first.end > last.end
452 {
453 return Err(AnchorError::UnorderedRange);
454 }
455 let start = first.start;
456 let end = last.end;
457 let mut cursor = start;
458 let mut chars = 0;
459 let mut bytes = 0;
460 let mut count = 0;
461 for p in ¶graphs[first.paragraph..=last.paragraph] {
462 if p.tab != a.tab || p.container != a.container {
463 return Err(AnchorError::UnsafeRange); }
465 if p.protected_newline && start < p.end && end >= p.end {
466 return Err(AnchorError::UnsafeRange);
467 }
468 count += 1;
469 for run in &p.runs {
470 if run.end <= start || run.start >= end {
471 continue;
472 }
473 if run.start > cursor {
474 return Err(AnchorError::UnsafeRange);
475 }
476 if run.suggested {
477 return Err(AnchorError::SuggestedContent);
478 }
479 let lo = cursor.max(run.start);
480 let hi = end.min(run.end);
481 let mut index = run.start;
482 for ch in run.text.chars() {
483 let next = index + ch.len_utf16() as i64;
484 if index >= lo && next <= hi {
485 chars += 1;
486 bytes += ch.len_utf8();
487 } else if index < hi && next > lo {
488 return Err(AnchorError::InvalidIndices); }
490 index = next;
491 }
492 cursor = hi;
493 }
494 }
495 if cursor != end {
496 return Err(AnchorError::UnsafeRange); }
498 Ok(EditPreview {
499 start_index: start,
500 end_index: end,
501 tab_id: a.tab_id.clone(),
502 segment_id: selection.segment_id.clone(),
503 segment_kind,
504 paragraphs: count,
505 chars,
506 bytes,
507 })
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
512pub struct ListPreview {
513 pub start_index: i64,
515 pub end_index: i64,
517 #[serde(skip_serializing_if = "Option::is_none")]
519 pub tab_id: Option<String>,
520 pub paragraphs: usize,
522 pub leading_tabs_removed: usize,
524}
525
526pub fn resolve_list(
529 document: &Document,
530 from: &str,
531 to: Option<&str>,
532 match_case: bool,
533 creating: bool,
534) -> Result<ListPreview, AnchorError> {
535 let (paragraphs, _) = paragraphs(document, &SegmentSelection::default())?;
537 let first = find(¶graphs, from, match_case)?;
538 let last = if let Some(to) = to {
539 find(¶graphs, to, match_case)?
540 } else {
541 first
542 };
543 let a = ¶graphs[first.paragraph];
544 let b = ¶graphs[last.paragraph];
545 if first.paragraph > last.paragraph
546 || a.tab != b.tab
547 || a.container != b.container
548 || first.start > last.start
549 || first.end > last.end
550 {
551 return Err(AnchorError::UnorderedRange);
552 }
553 let mut cursor = a.start;
554 let mut leading_tabs_removed = 0;
555 for p in ¶graphs[first.paragraph..=last.paragraph] {
556 if p.tab != a.tab || p.container != a.container || p.start != cursor {
557 return Err(AnchorError::UnsafeRange);
558 }
559 let mut leading = creating;
560 for run in &p.runs {
561 if run.start != cursor {
562 return Err(AnchorError::UnsafeRange);
563 }
564 if run.suggested {
565 return Err(AnchorError::SuggestedContent);
566 }
567 for ch in run.text.chars() {
568 if leading && ch == '\t' {
569 leading_tabs_removed += 1;
570 } else {
571 leading = false;
572 }
573 }
574 cursor = run.end;
575 }
576 if cursor != p.end {
577 return Err(AnchorError::UnsafeRange); }
579 if p.protected_newline && p.end != b.end {
582 return Err(AnchorError::UnsafeRange);
583 }
584 }
585 Ok(ListPreview {
586 start_index: a.start,
587 end_index: b.end,
588 tab_id: a.tab_id.clone(),
589 paragraphs: last.paragraph - first.paragraph + 1,
590 leading_tabs_removed,
591 })
592}
593
594pub fn resolve_format(
597 document: &Document,
598 from: &str,
599 to: Option<&str>,
600 match_case: bool,
601 whole_paragraphs: bool,
602) -> Result<EditPreview, AnchorError> {
603 let mut edit = resolve_delete(document, from, to, match_case)?;
604 let (paragraphs, _) = paragraphs(document, &SegmentSelection::default())?;
605 if !whole_paragraphs {
606 for p in paragraphs.iter().filter(|p| p.tab_id == edit.tab_id) {
610 for run in &p.runs {
611 if run.suggested
612 && ((run.end == edit.start_index && run.text.ends_with('\n'))
613 || (run.start == edit.end_index && run.text.starts_with('\n')))
614 {
615 return Err(AnchorError::SuggestedContent);
616 }
617 }
618 }
619 return Ok(edit);
620 }
621 let affected: Vec<_> = paragraphs
622 .iter()
623 .filter(|p| {
624 p.tab_id == edit.tab_id && p.start.max(edit.start_index) < p.end.min(edit.end_index)
625 })
626 .collect();
627 let first = affected.first().ok_or(AnchorError::InvalidIndices)?;
628 let last = affected.last().ok_or(AnchorError::InvalidIndices)?;
629 let mut cursor = first.start;
630 edit.chars = 0;
631 edit.bytes = 0;
632 for p in &affected {
633 if p.container != first.container || p.start != cursor {
634 return Err(AnchorError::UnsafeRange);
635 }
636 for run in &p.runs {
637 if run.start != cursor {
638 return Err(AnchorError::UnsafeRange);
639 }
640 if run.suggested {
641 return Err(AnchorError::SuggestedContent);
642 }
643 edit.chars += run.text.chars().count();
644 edit.bytes += run.text.len();
645 cursor = run.end;
646 }
647 if cursor != p.end {
648 return Err(AnchorError::UnsafeRange); }
650 }
651 edit.start_index = first.start;
652 edit.end_index = last.end;
653 edit.paragraphs = affected.len();
654 Ok(edit)
655}
656
657#[cfg(test)]
658#[allow(clippy::unwrap_used, clippy::expect_used)]
659mod tests {
660 use super::*;
661 use proptest::prelude::*;
662 use serde_json::{json, Value};
663
664 fn paragraph(start: i64, runs: &[&str]) -> Value {
665 let mut index = start;
666 let elements: Vec<Value> = runs
667 .iter()
668 .map(|text| {
669 let end = index + text.encode_utf16().count() as i64;
670 let element =
671 json!({"startIndex": index, "endIndex": end, "textRun": {"content": text}});
672 index = end;
673 element
674 })
675 .collect();
676 json!({"startIndex": start, "endIndex": index, "paragraph": {"elements": elements}})
677 }
678
679 fn doc(content: Vec<Value>) -> Document {
680 serde_json::from_value(json!({"body": {"content": content}})).unwrap()
681 }
682
683 fn selection(id: &str) -> SegmentSelection {
684 SegmentSelection {
685 segment_id: Some(id.into()),
686 tab_id: None,
687 }
688 }
689
690 #[test]
691 fn all_segment_families_use_zero_based_utf16_and_ignore_other_content() {
692 for (map, kind) in [
693 ("headers", SegmentKind::Header),
694 ("footers", SegmentKind::Footer),
695 ("footnotes", SegmentKind::Footnote),
696 ] {
697 let mut p = paragraph(0, &["š a", "nchor\n"]);
698 p.as_object_mut().unwrap().remove("startIndex");
699 p["paragraph"]["elements"][0]
700 .as_object_mut()
701 .unwrap()
702 .remove("startIndex");
703 let document: Document = serde_json::from_value(json!({
704 "body": {"content": [paragraph(1, &["anchor anchor\n"])]},
705 map: {"s": {"content": [p]}, "other": {"content": [{"paragraph": {}}]}}
706 }))
707 .unwrap();
708 let edit = resolve_delete_in(&document, "anchor", None, true, &selection("s")).unwrap();
709 assert_eq!(
710 (edit.start_index, edit.end_index, edit.chars, edit.bytes),
711 (3, 9, 6, 6)
712 );
713 assert_eq!(edit.segment_id.as_deref(), Some("s"));
714 assert_eq!(edit.segment_kind, Some(kind));
715 assert_eq!(
716 resolve_insert_in(&document, "š", Side::Before, "x", true, &selection("s"))
717 .unwrap()
718 .start_index,
719 0
720 );
721 assert_eq!(
722 resolve_delete(&document, "anchor", None, true),
723 Err(AnchorError::Ambiguous { count: 2 })
724 );
725 }
726 }
727
728 #[test]
729 fn segment_identity_is_resolved_before_anchor_uniqueness_in_child_tabs() {
730 let document: Document = serde_json::from_value(json!({"tabs": [{
731 "tabProperties": {"tabId": "parent"},
732 "documentTab": {"headers": {"s": {"content": [paragraph(0, &["other\n"])]}}},
733 "childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": {"footnotes": {"s": {"content": [paragraph(0, &["anchor\n"])]}}}}]
734 }]})).unwrap();
735 assert_eq!(
736 resolve_delete_in(&document, "anchor", None, true, &selection("s")),
737 Err(AnchorError::AmbiguousSegment)
738 );
739 let mut selected = selection("s");
740 selected.tab_id = Some("child".into());
741 let edit = resolve_delete_in(&document, "anchor", None, true, &selected).unwrap();
742 assert_eq!(edit.tab_id.as_deref(), Some("child"));
743 assert_eq!(edit.segment_kind, Some(SegmentKind::Footnote));
744 selected.tab_id = Some("missing".into());
745 assert_eq!(
746 resolve_delete_in(&document, "anchor", None, true, &selected),
747 Err(AnchorError::SegmentNotFound)
748 );
749 for invalid in [
750 selection(""),
751 SegmentSelection {
752 segment_id: None,
753 tab_id: Some("child".into()),
754 },
755 SegmentSelection {
756 segment_id: Some("s".into()),
757 tab_id: Some(String::new()),
758 },
759 ] {
760 assert_eq!(
761 resolve_delete_in(&document, "anchor", None, true, &invalid),
762 Err(AnchorError::InvalidSelection)
763 );
764 }
765 }
766
767 #[test]
768 fn selected_segments_preserve_suggestion_and_structural_guards() {
769 let mut suggested = paragraph(0, &["anchor\n"]);
770 suggested["paragraph"]["elements"][0]["textRun"]["suggestedDeletionIds"] =
771 json!(["suggestion"]);
772 let document: Document =
773 serde_json::from_value(json!({"headers": {"s": {"content": [suggested]}}})).unwrap();
774 assert_eq!(
775 resolve_delete_in(&document, "anchor", None, true, &selection("s")),
776 Err(AnchorError::SuggestedContent)
777 );
778 assert_eq!(
779 resolve_insert_in(&document, "anchor", Side::After, "x", true, &selection("s")),
780 Err(AnchorError::SuggestedContent)
781 );
782 let document: Document = serde_json::from_value(json!({"footers": {"s": {"content": [
783 paragraph(0, &["first\n"]), {"startIndex": 6, "endIndex": 7, "sectionBreak": {}}, paragraph(7, &["last\n"])
784 ]}}})).unwrap();
785 assert_eq!(
786 resolve_delete_in(&document, "first", Some("last"), true, &selection("s")),
787 Err(AnchorError::UnsafeRange)
788 );
789 assert_eq!(
790 resolve_delete_in(&document, "last\n", None, true, &selection("s")),
791 Err(AnchorError::InvalidAnchor)
792 );
793 let edit = resolve_delete_in(&document, "last", None, true, &selection("s")).unwrap();
794 assert_eq!(edit.end_index, 11); }
796
797 #[test]
798 fn segment_table_cells_and_inline_gaps_cannot_be_crossed() {
799 let gap = json!({"endIndex": 4, "paragraph": {"elements": [
800 {"endIndex": 1, "textRun": {"content": "a"}},
801 {"startIndex": 1, "endIndex": 2, "footnoteReference": {"footnoteId": "note"}},
802 {"startIndex": 2, "endIndex": 4, "textRun": {"content": "b\n"}}
803 ]}});
804 let document: Document =
805 serde_json::from_value(json!({"footnotes": {"s": {"content": [gap]}}})).unwrap();
806 assert_eq!(
807 resolve_delete_in(&document, "ab", None, true, &selection("s")),
808 Err(AnchorError::NotFound)
809 );
810 assert_eq!(
811 resolve_delete_in(&document, "a", Some("b"), true, &selection("s")),
812 Err(AnchorError::UnsafeRange)
813 );
814 let document: Document = serde_json::from_value(json!({"headers": {"s": {"content": [
815 paragraph(0, &["before\n"]), {"startIndex": 7, "endIndex": 30, "table": {"tableRows": [{"tableCells": [
816 {"content": [paragraph(9, &["first\n"])]}, {"content": [paragraph(20, &["last\n"])]}
817 ]}]}}
818 ]}}})).unwrap();
819 assert_eq!(
820 resolve_delete_in(&document, "first", Some("last"), true, &selection("s")),
821 Err(AnchorError::UnorderedRange)
822 );
823 assert_eq!(
824 resolve_delete_in(&document, "before", Some("first"), true, &selection("s")),
825 Err(AnchorError::UnorderedRange)
826 );
827 assert_eq!(
828 resolve_delete_in(&document, "first", None, true, &selection("s"))
829 .unwrap()
830 .end_index,
831 14
832 );
833 }
834
835 #[test]
836 fn astral_characters_and_split_runs_use_server_utf16_indices() {
837 let document = doc(vec![paragraph(1, &["š a", "nchor\n"])]);
838 let edit = resolve_insert(&document, "anchor", Side::After, "x", true).unwrap();
839 assert_eq!((edit.start_index, edit.end_index), (10, 10));
840 let edit = resolve_delete(&document, "anchor", None, true).unwrap();
841 assert_eq!((edit.start_index, edit.end_index, edit.chars), (4, 10, 6));
842 }
843
844 #[test]
845 fn unicode_case_folding_preserves_original_boundaries_and_matches_literals() {
846 let document = doc(vec![paragraph(1, &["š \u{212a}.[İ]\n"])]);
847 let edit = resolve_delete(&document, "k.[İ]", None, false).unwrap();
848 assert_eq!(
849 (edit.start_index, edit.end_index, edit.chars, edit.bytes),
850 (4, 9, 5, 8)
851 );
852 assert_eq!(
853 resolve_delete(&document, "k.[İ]", None, true),
854 Err(AnchorError::NotFound)
855 );
856 }
857
858 #[test]
859 fn overlapping_matches_are_ambiguous_and_paragraph_breaks_are_not_anchors() {
860 let document = doc(vec![paragraph(1, &["aaa\n"])]);
861 assert_eq!(
862 resolve_delete(&document, "aa", None, true),
863 Err(AnchorError::Ambiguous { count: 2 })
864 );
865 assert_eq!(
866 resolve_delete(&document, "a\na", None, true),
867 Err(AnchorError::InvalidAnchor)
868 );
869 assert_eq!(
870 resolve_delete(&document, "", None, true),
871 Err(AnchorError::InvalidAnchor)
872 );
873 assert_eq!(
874 resolve_delete(&document, "absent", None, true),
875 Err(AnchorError::NotFound)
876 );
877 }
878
879 #[test]
880 fn inline_objects_do_not_shift_later_anchors_or_manufacture_matches() {
881 let document = doc(vec![
882 json!({"startIndex": 1, "endIndex": 5, "paragraph": {"elements": [
883 {"startIndex": 1, "endIndex": 2, "textRun": {"content": "a"}},
884 {"startIndex": 2, "endIndex": 3, "inlineObjectElement": {"inlineObjectId": "img"}},
885 {"startIndex": 3, "endIndex": 5, "textRun": {"content": "b\n"}}
886 ]}}),
887 ]);
888 assert_eq!(
889 resolve_insert(&document, "b", Side::Before, "x", true)
890 .unwrap()
891 .start_index,
892 3
893 );
894 assert_eq!(
895 resolve_delete(&document, "ab", None, true),
896 Err(AnchorError::NotFound)
897 );
898 assert_eq!(
899 resolve_delete(&document, "a", Some("b"), true),
900 Err(AnchorError::UnsafeRange)
901 );
902 }
903
904 #[test]
905 fn incomplete_or_inconsistent_indices_are_refused() {
906 let mut p = paragraph(1, &["anchor\n"]);
907 p["paragraph"]["elements"][0]
908 .as_object_mut()
909 .unwrap()
910 .remove("startIndex");
911 assert_eq!(
912 resolve_delete(&doc(vec![p]), "anchor", None, true),
913 Err(AnchorError::InvalidIndices)
914 );
915 let mut p = paragraph(1, &["š anchor\n"]);
916 p["paragraph"]["elements"][0]["endIndex"] = json!(8);
917 assert_eq!(
918 resolve_delete(&doc(vec![p]), "anchor", None, true),
919 Err(AnchorError::InvalidIndices)
920 );
921 }
922
923 #[test]
924 fn paragraph_and_run_indices_that_do_not_nest_are_refused() {
925 type Mutation = fn(&mut Value);
926 let cases: [(&str, Mutation); 7] = [
927 ("paragraph starts before the body", |p| {
928 p["startIndex"] = json!(0);
929 }),
930 ("paragraph is empty", |p| {
931 p["endIndex"] = json!(1);
932 }),
933 ("run overlaps the previous run", |p| {
934 p["paragraph"]["elements"][1]["startIndex"] = json!(2);
935 }),
936 ("run is empty", |p| {
937 p["paragraph"]["elements"][1]["endIndex"] = json!(4);
938 }),
939 ("run overruns its paragraph", |p| {
940 p["endIndex"] = json!(7);
941 }),
942 ("last run is not newline-terminated", |p| {
943 p["paragraph"]["elements"][1]["textRun"]["content"] = json!("def");
944 p["paragraph"]["elements"][1]["endIndex"] = json!(7);
945 p["endIndex"] = json!(7);
946 }),
947 ("last run stops short of the paragraph end", |p| {
948 p["endIndex"] = json!(9);
949 }),
950 ];
951 for (name, mutate) in cases {
952 let mut p = paragraph(1, &["abc", "def\n"]);
953 mutate(&mut p);
954 assert_eq!(
955 resolve_delete(&doc(vec![p]), "abc", None, true),
956 Err(AnchorError::InvalidIndices),
957 "{name}"
958 );
959 }
960 let mut p = paragraph(1, &["abc\n"]);
961 p["paragraph"]["elements"] = json!([]);
962 assert_eq!(
963 resolve_delete(&doc(vec![p]), "abc", None, true),
964 Err(AnchorError::InvalidIndices),
965 "a paragraph with no runs"
966 );
967 }
968
969 #[test]
970 fn suggestions_in_anchors_and_inside_ranges_are_refused() {
971 for field in ["suggestedInsertionIds", "suggestedDeletionIds"] {
972 let mut p = paragraph(1, &["start ", "pending", " finish\n"]);
973 p["paragraph"]["elements"][1]["textRun"][field] = json!(["suggestion"]);
974 let document = doc(vec![p]);
975 assert_eq!(
976 resolve_insert(&document, "pending", Side::After, "x", true),
977 Err(AnchorError::SuggestedContent)
978 );
979 assert_eq!(
980 resolve_delete(&document, "start", Some("finish"), true),
981 Err(AnchorError::SuggestedContent)
982 );
983 assert!(resolve_delete(&document, "start", None, true).is_ok());
984 }
985 }
986
987 #[test]
988 fn safe_ranges_span_paragraphs_and_preserve_the_last_newline() {
989 let document = doc(vec![paragraph(1, &["start\n"]), paragraph(7, &["end\n"])]);
990 let edit = resolve_delete(&document, "start", Some("end"), true).unwrap();
991 assert_eq!(
992 (
993 edit.start_index,
994 edit.end_index,
995 edit.chars,
996 edit.paragraphs
997 ),
998 (1, 10, 9, 2)
999 );
1000 assert_eq!(
1001 resolve_delete(&document, "end", Some("start"), true),
1002 Err(AnchorError::UnorderedRange)
1003 );
1004 assert_eq!(
1005 resolve_insert(&document, "end", Side::After, "x", true)
1006 .unwrap()
1007 .start_index,
1008 10
1009 );
1010 assert_eq!(
1011 resolve_insert(&document, "end\n", Side::After, "x", true),
1012 Err(AnchorError::InvalidAnchor)
1013 );
1014 }
1015
1016 #[test]
1017 fn tables_cells_and_tabs_have_independent_boundaries() {
1018 let table = json!({"startIndex": 8, "endIndex": 30, "table": {"tableRows": [{"tableCells": [
1019 {"content": [paragraph(11, &["cell\n"])]},
1020 {"content": [paragraph(18, &["other\n"])]}
1021 ]}]}});
1022 let document = doc(vec![
1023 paragraph(1, &["before\n"]),
1024 table,
1025 paragraph(30, &["after\n"]),
1026 ]);
1027 assert_eq!(
1028 resolve_insert(&document, "cell", Side::After, "x", true)
1029 .unwrap()
1030 .start_index,
1031 15
1032 );
1033 assert_eq!(
1034 resolve_delete(&document, "cell", Some("other"), true),
1035 Err(AnchorError::UnorderedRange)
1036 );
1037 assert_eq!(
1038 resolve_delete(&document, "before", Some("after"), true),
1039 Err(AnchorError::UnsafeRange)
1040 );
1041 let tabbed: Document = serde_json::from_value(json!({"tabs": [
1042 {"tabProperties": {"tabId": "t1"}, "documentTab": {"body": {"content": [paragraph(1, &["start\n"])]}},
1043 "childTabs": [{"tabProperties": {"tabId": "t2"}, "documentTab": {"body": {"content": [paragraph(1, &["end\n"])]}}}]}
1044 ]})).unwrap();
1045 assert_eq!(
1046 resolve_insert(&tabbed, "end", Side::Before, "x", true)
1047 .unwrap()
1048 .tab_id
1049 .as_deref(),
1050 Some("t2")
1051 );
1052 assert_eq!(
1053 resolve_delete(&tabbed, "start", Some("end"), true),
1054 Err(AnchorError::UnorderedRange)
1055 );
1056 }
1057
1058 #[test]
1059 fn non_body_and_table_of_contents_text_is_not_addressable() {
1060 let document: Document = serde_json::from_value(json!({
1061 "headers": {"h1": {"content": [paragraph(1, &["header\n"])]}},
1062 "body": {"content": [{"tableOfContents": {"content": [paragraph(1, &["toc\n"])]}}]}
1063 }))
1064 .unwrap();
1065 for needle in ["header", "toc"] {
1066 assert_eq!(
1067 resolve_delete(&document, needle, None, true),
1068 Err(AnchorError::NotFound)
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn lists_expand_unicode_anchors_to_whole_paragraphs_and_count_split_leading_tabs() {
1075 let document = doc(vec![
1076 paragraph(1, &["\t", "\tš K", "elvin\n"]),
1077 paragraph(13, &["\tlast\n"]),
1078 paragraph(19, &["untouched\n"]),
1079 ]);
1080 let edit = resolve_list(&document, "kelvin", Some("last"), false, true).unwrap();
1081 assert_eq!(
1082 (
1083 edit.start_index,
1084 edit.end_index,
1085 edit.paragraphs,
1086 edit.leading_tabs_removed
1087 ),
1088 (1, 19, 2, 3)
1089 );
1090 let single = resolve_list(&document, "last", None, true, false).unwrap();
1091 assert_eq!(
1092 (
1093 single.start_index,
1094 single.end_index,
1095 single.leading_tabs_removed
1096 ),
1097 (13, 19, 0)
1098 );
1099 assert_eq!(
1100 resolve_list(&document, "last", Some("Kelvin"), true, true),
1101 Err(AnchorError::UnorderedRange)
1102 );
1103 assert_eq!(
1104 resolve_list(&document, "Kelvin", None, false, true)
1105 .unwrap()
1106 .end_index,
1107 13
1108 );
1109 }
1110
1111 #[test]
1112 fn list_formatting_refuses_objects_gaps_structures_and_suggestions_outside_anchor() {
1113 let object = json!({"startIndex": 1, "endIndex": 5, "paragraph": {"elements": [
1114 {"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "img"}},
1115 {"startIndex": 2, "endIndex": 5, "textRun": {"content": "ok\n"}}
1116 ]}});
1117 assert_eq!(
1118 resolve_list(&doc(vec![object]), "ok", None, true, true),
1119 Err(AnchorError::UnsafeRange)
1120 );
1121 let gap = doc(vec![paragraph(1, &["first\n"]), paragraph(8, &["last\n"])]);
1122 assert_eq!(
1123 resolve_list(&gap, "first", Some("last"), true, true),
1124 Err(AnchorError::UnsafeRange)
1125 );
1126 let structure = doc(vec![
1128 paragraph(1, &["first\n"]),
1129 json!({"sectionBreak": {}}),
1130 paragraph(7, &["last\n"]),
1131 ]);
1132 assert_eq!(
1133 resolve_list(&structure, "first", Some("last"), true, true),
1134 Err(AnchorError::UnsafeRange)
1135 );
1136 for creating in [true, false] {
1137 for field in ["suggestedInsertionIds", "suggestedDeletionIds"] {
1138 let mut p = paragraph(1, &["\t", "anchor", " pending\n"]);
1139 p["paragraph"]["elements"][0]["textRun"][field] = json!(["s"]);
1140 assert_eq!(
1141 resolve_list(&doc(vec![p]), "anchor", None, true, creating),
1142 Err(AnchorError::SuggestedContent)
1143 );
1144 }
1145 }
1146 let out_of_order = doc(vec![
1147 paragraph(10, &["later\n"]),
1148 paragraph(1, &["earlier\n"]),
1149 ]);
1150 assert_eq!(
1151 resolve_list(&out_of_order, "earlier", Some("later"), true, true),
1152 Err(AnchorError::UnorderedRange)
1153 );
1154 let mut invalid = paragraph(1, &["anchor\n"]);
1155 invalid["endIndex"] = json!(9);
1156 assert_eq!(
1157 resolve_list(&doc(vec![invalid]), "anchor", None, true, true),
1158 Err(AnchorError::InvalidIndices)
1159 );
1160 }
1161
1162 #[test]
1163 fn lists_respect_table_cells_and_nested_tab_identity_and_final_newlines() {
1164 let table = json!({"table": {"tableRows": [{"tableCells": [
1165 {"content": [paragraph(11, &["cell\n"])]},
1166 {"content": [paragraph(18, &["other\n"])]}
1167 ]}]}});
1168 let document = doc(vec![
1169 paragraph(1, &["before\n"]),
1170 table,
1171 paragraph(30, &["after\n"]),
1172 ]);
1173 assert_eq!(
1174 resolve_list(&document, "cell", None, true, true)
1175 .unwrap()
1176 .end_index,
1177 16
1178 );
1179 assert_eq!(
1180 resolve_list(&document, "cell", Some("other"), true, false),
1181 Err(AnchorError::UnorderedRange)
1182 );
1183 assert_eq!(
1184 resolve_list(&document, "before", Some("after"), true, true),
1185 Err(AnchorError::UnsafeRange)
1186 );
1187 let tabbed: Document = serde_json::from_value(json!({"tabs": [
1188 {"tabProperties": {"tabId": "t1"}, "documentTab": {"body": {"content": [paragraph(1, &["first\n"])]}},
1189 "childTabs": [{"tabProperties": {"tabId": "t2"}, "documentTab": {"body": {"content": [paragraph(1, &["last\n"])]}}}]}
1190 ]})).unwrap();
1191 let edit = resolve_list(&tabbed, "last", None, true, true).unwrap();
1192 assert_eq!(edit.tab_id.as_deref(), Some("t2"));
1193 assert_eq!((edit.start_index, edit.end_index), (1, 6));
1194 assert_eq!(
1195 resolve_list(&tabbed, "first", Some("last"), true, true),
1196 Err(AnchorError::UnorderedRange)
1197 );
1198 assert_eq!(
1199 resolve_list(&doc(vec![paragraph(1, &["aaa\n"])]), "aa", None, true, true),
1200 Err(AnchorError::Ambiguous { count: 2 })
1201 );
1202 }
1203
1204 proptest! {
1205 #[test]
1206 fn random_unicode_prefixes_and_run_splits_preserve_utf16_offsets(
1207 prefix in prop::collection::vec(prop_oneof![Just('š'), Just('š '), Just('Ć©'), Just('x')], 0..80),
1208 split in 0usize..100,
1209 ) {
1210 let prefix: String = prefix.into_iter().collect();
1211 let text = format!("{prefix}ANCHOR\n");
1212 let boundaries: Vec<usize> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
1213 let byte = boundaries[split % boundaries.len()];
1214 let runs: Vec<&str> = [&text[..byte], &text[byte..]].into_iter().filter(|s| !s.is_empty()).collect();
1215 let document = doc(vec![paragraph(1, &runs)]);
1216 let edit = resolve_delete(&document, "ANCHOR", None, true).unwrap();
1217 prop_assert_eq!(edit.start_index, 1 + prefix.encode_utf16().count() as i64);
1218 prop_assert_eq!(edit.end_index, edit.start_index + 6);
1219 let list = resolve_list(&document, "ANCHOR", None, true, true).unwrap();
1220 prop_assert_eq!(list.start_index, 1);
1221 prop_assert_eq!(list.end_index, 1 + text.encode_utf16().count() as i64);
1222 prop_assert_eq!(list.paragraphs, 1);
1223 }
1224 }
1225 #[test]
1226 fn insertion_counts_exclude_characters_stripped_by_google() {
1227 let document = doc(vec![paragraph(1, &["anchor\n"])]);
1228 let edit =
1229 resolve_insert(&document, "anchor", Side::After, "\0š\r\u{e000}x\n", true).unwrap();
1230 assert_eq!((edit.chars, edit.bytes), (3, 6));
1231 assert_eq!(
1232 resolve_insert(&document, "anchor", Side::After, "\0\r", true),
1233 Err(AnchorError::InvalidInsertionText)
1234 );
1235 }
1236
1237 #[test]
1238 fn missing_empty_or_duplicate_tab_ids_are_refused() {
1239 for ids in [vec![None], vec![Some("")], vec![Some("same"), Some("same")]] {
1240 let tabs: Vec<Value> = ids.into_iter().map(|id| json!({
1241 "tabProperties": {"tabId": id}, "documentTab": {"body": {"content": [paragraph(1, &["anchor\n"])]}}
1242 })).collect();
1243 let document: Document = serde_json::from_value(json!({"tabs": tabs})).unwrap();
1244 assert_eq!(
1245 resolve_insert(&document, "anchor", Side::Before, "x", true),
1246 Err(AnchorError::InvalidIndices)
1247 );
1248 assert_eq!(
1249 resolve_list(&document, "anchor", None, true, true),
1250 Err(AnchorError::InvalidIndices)
1251 );
1252 }
1253 }
1254 #[test]
1255 fn formatting_resolves_utf16_and_expands_whole_paragraph_effects() {
1256 let document = doc(vec![
1257 paragraph(1, &["š pre ", "anchor tail\n"]),
1258 paragraph(21, &["end\n"]),
1259 ]);
1260 let text = resolve_format(&document, "anchor", None, true, false).unwrap();
1261 assert_eq!(
1262 (text.start_index, text.end_index, text.chars, text.bytes),
1263 (8, 14, 6, 6)
1264 );
1265 let p = resolve_format(&document, "anchor", None, true, true).unwrap();
1266 assert_eq!(
1267 (p.start_index, p.end_index, p.chars, p.bytes, p.paragraphs),
1268 (1, 20, 18, 21, 1)
1269 );
1270 assert!(resolve_format(&document, "anchor", Some("end"), true, true).is_err());
1272 let document = doc(vec![
1273 paragraph(1, &["š pre ", "anchor tail\n"]),
1274 paragraph(20, &["end\n"]),
1275 ]);
1276 let p = resolve_format(&document, "anchor", Some("end"), true, true).unwrap();
1277 assert_eq!(
1278 (p.start_index, p.end_index, p.paragraphs, p.chars, p.bytes),
1279 (1, 24, 2, 22, 25)
1280 );
1281 }
1282
1283 #[test]
1284 fn paragraph_formatting_refuses_suggestions_and_objects_outside_anchors() {
1285 let mut p = paragraph(1, &["prefix ", "anchor\n"]);
1286 p["paragraph"]["elements"][0]["textRun"]["suggestedInsertionIds"] = json!(["pending"]);
1287 let document = doc(vec![p]);
1288 assert!(resolve_format(&document, "anchor", None, true, false).is_ok());
1289 assert_eq!(
1290 resolve_format(&document, "anchor", None, true, true),
1291 Err(AnchorError::SuggestedContent)
1292 );
1293 let document = doc(vec![
1294 json!({"startIndex": 1, "endIndex": 9, "paragraph": {"elements": [
1295 {"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "img"}},
1296 {"startIndex": 2, "endIndex": 9, "textRun": {"content": "anchor\n"}}
1297 ]}}),
1298 ]);
1299 assert!(resolve_format(&document, "anchor", None, true, false).is_ok());
1300 assert_eq!(
1301 resolve_format(&document, "anchor", None, true, true),
1302 Err(AnchorError::UnsafeRange)
1303 );
1304 }
1305
1306 #[test]
1310 fn paragraph_formatting_refuses_paragraphs_whose_indices_overlap() {
1311 let document = doc(vec![
1312 paragraph(1, &["aaaaaa\n"]),
1313 paragraph(5, &["bbbbbb\n"]),
1314 ]);
1315 assert!(resolve_delete(&document, "aaaaaa", Some("bbbbbb"), true).is_ok());
1316 assert!(resolve_format(&document, "aaaaaa", Some("bbbbbb"), true, false).is_ok());
1317 assert_eq!(
1318 resolve_format(&document, "aaaaaa", Some("bbbbbb"), true, true),
1319 Err(AnchorError::UnsafeRange)
1320 );
1321 }
1322
1323 #[test]
1324 fn paragraph_formatting_preserves_nested_tab_identity() {
1325 let document: Document = serde_json::from_value(json!({"tabs": [{"tabProperties": {"tabId": "parent"}, "childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": {"body": {"content": [paragraph(1, &["š anchor\n"])]}}}]}]})).unwrap();
1326 let p = resolve_format(&document, "anchor", None, true, true).unwrap();
1327 assert_eq!(p.tab_id.as_deref(), Some("child"));
1328 assert_eq!((p.start_index, p.end_index), (1, 11));
1329 }
1330 #[test]
1331 fn text_formatting_refuses_suggested_adjacent_newlines() {
1332 let mut p = paragraph(1, &["anchor", "\n"]);
1333 p["paragraph"]["elements"][1]["textRun"]["suggestedDeletionIds"] = json!(["pending"]);
1334 assert_eq!(
1335 resolve_format(&doc(vec![p]), "anchor", None, true, false),
1336 Err(AnchorError::SuggestedContent)
1337 );
1338 let mut preceding = paragraph(1, &["before", "\n"]);
1339 preceding["paragraph"]["elements"][1]["textRun"]["suggestedInsertionIds"] =
1340 json!(["pending"]);
1341 assert_eq!(
1342 resolve_format(
1343 &doc(vec![preceding, paragraph(8, &["anchor\n"])]),
1344 "anchor",
1345 None,
1346 true,
1347 false
1348 ),
1349 Err(AnchorError::SuggestedContent)
1350 );
1351 assert!(resolve_format(
1353 &doc(vec![paragraph(1, &["anchor", "\n"])]),
1354 "anchor",
1355 None,
1356 true,
1357 false
1358 )
1359 .is_ok());
1360 }
1361}