Skip to main content

omni_dev/drive/docs/
anchor.rs

1//! Content anchors resolved against the same inline snapshot as the write lease.
2//!
3//! This is the production UTF-16 arithmetic boundary. Offsets are computed
4//! within server-indexed text runs, never by summing document text. Non-text
5//! elements break matches; ranges cannot cross containers or structural gaps.
6
7use std::collections::HashSet;
8
9use regex::RegexBuilder;
10use serde::Serialize;
11
12use super::types::{Document, StructuralElement};
13
14/// Why an anchor or range cannot safely address an edit. Never contains prose.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16#[serde(tag = "reason", rename_all = "kebab-case")]
17pub enum AnchorError {
18    /// The anchor is empty or contains a paragraph separator.
19    InvalidAnchor,
20    /// No characters would remain after the API strips unsupported input.
21    InvalidInsertionText,
22    /// The segment selector is empty or inconsistent.
23    InvalidSelection,
24    /// No selected segment exists.
25    SegmentNotFound,
26    /// A segment ID identifies more than one segment.
27    AmbiguousSegment,
28    /// No selected text matches.
29    NotFound,
30    /// More than one occurrence, including overlapping occurrences.
31    Ambiguous {
32        /// Number of matches across all tab bodies.
33        count: usize,
34    },
35    /// Missing or inconsistent server indices or tab identity.
36    InvalidIndices,
37    /// The edit overlaps pending content suggestions.
38    SuggestedContent,
39    /// The range crosses a structural boundary or includes a protected newline.
40    UnsafeRange,
41    /// The second anchor precedes the first or is in another tab/container.
42    UnorderedRange,
43}
44
45/// An explicit existing segment, optionally narrowed to one tab.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct SegmentSelection {
48    /// Header, footer or footnote map key; absent selects all tab bodies.
49    pub segment_id: Option<String>,
50    /// Optional tab identity; requires a segment ID.
51    pub tab_id: Option<String>,
52}
53
54impl SegmentSelection {
55    /// Reject empty identifiers and a tab without a segment before I/O.
56    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/// Kind of an explicitly selected non-body segment.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum SegmentKind {
73    /// Page header.
74    Header,
75    /// Page footer.
76    Footer,
77    /// Footnote content.
78    Footnote,
79}
80
81/// Whether inserted text goes before or after the unique anchor.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Side {
84    /// Before the first code unit of the anchor.
85    Before,
86    /// After the last code unit of the anchor.
87    After,
88}
89
90/// Metadata shared by a dry-run and a successful edit. No document prose.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct EditPreview {
93    /// Inclusive start in server UTF-16 code units.
94    pub start_index: i64,
95    /// Exclusive end; equals start for insertion.
96    pub end_index: i64,
97    /// The addressed tab; absent only for legacy top-level body responses.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub tab_id: Option<String>,
100    /// Explicit non-body segment identity.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub segment_id: Option<String>,
103    /// Selected segment family.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub segment_kind: Option<SegmentKind>,
106    /// Number of paragraphs touched.
107    pub paragraphs: usize,
108    /// Unicode scalar values inserted or removed.
109    pub chars: usize,
110    /// UTF-8 bytes inserted or removed.
111    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    // The last newline of a container, or one preceding a structural element.
128    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 &paragraph.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        // Tables of contents remain deliberately uneditable.
279    }
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(&regex::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        // Group contiguous text runs. A missing index unit (image, break, etc.)
299        // must not disappear and manufacture a match over its neighbours.
300        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                // Advance one scalar from the match start, so overlaps count too.
323                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) // omni-dev: coverage ignore-line reason="map_boundary is only called with a byte offset inside the text its runs concatenate, so the loop always returns first; the Err keeps the function total without a panic"
352}
353
354/// Counts the text Google actually inserts, excluding documented stripped
355/// control characters and BMP private-use characters. The original text is
356/// still sent verbatim; the server performs this filtering.
357pub(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
362/// Resolve insertion against a body anchor in the leased inline snapshot.
363pub 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
380/// Resolve an anchored insert within an explicitly selected segment or tab bodies.
381pub 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(&paragraphs, anchor, match_case)?;
395    if found.suggested {
396        return Err(AnchorError::SuggestedContent);
397    }
398    let p = &paragraphs[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); // omni-dev: coverage ignore-line reason="a match never contains the paragraph's closing newline (find rejects a needle with one) and collect verified the last run ends at it, so the index always lies inside the paragraph; kept as defence on the write boundary"
405    }
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
418/// Resolve deletion of one unique match, or an inclusive pair of anchors.
419///
420/// Cross-paragraph deletion is permitted only through contiguous plain text
421/// within the same body or table cell. Protected newlines cannot be removed.
422pub 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
431/// Resolve an anchored delete within an explicitly selected segment or tab bodies.
432pub 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(&paragraphs, from, match_case)?;
441    let last = if let Some(to) = to {
442        find(&paragraphs, to, match_case)?
443    } else {
444        first
445    };
446    let a = &paragraphs[first.paragraph];
447    let b = &paragraphs[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 &paragraphs[first.paragraph..=last.paragraph] {
462        if p.tab != a.tab || p.container != a.container {
463            return Err(AnchorError::UnsafeRange); // omni-dev: coverage ignore-line reason="a container switch between two paragraphs of one container is always a table, and the paragraph before it has protected_newline, so the check below refuses the range before the walk reaches a foreign paragraph; kept as defence in depth"
464        }
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); // omni-dev: coverage ignore-line reason="lo and hi are regex match boundaries mapped through whole runs, so neither falls inside a surrogate pair; kept as defence in the UTF-16 arithmetic"
489                }
490                index = next;
491            }
492            cursor = hi;
493        }
494    }
495    if cursor != end {
496        return Err(AnchorError::UnsafeRange); // omni-dev: coverage ignore-line reason="end is the end of the last anchor, which lies inside a run the loop above visits, and a gap before it already returned UnsafeRange, so the cursor always reaches it; kept as defence in depth"
497    }
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/// Whole-paragraph formatting metadata. Indices describe the pre-write snapshot.
511#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
512pub struct ListPreview {
513    /// Inclusive start in server UTF-16 units.
514    pub start_index: i64,
515    /// Exclusive end, including the last selected paragraph's newline.
516    pub end_index: i64,
517    /// Absent only for legacy top-level body responses.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub tab_id: Option<String>,
520    /// Number of complete paragraphs selected.
521    pub paragraphs: usize,
522    /// Leading tabs Google removes on creation; zero for removal.
523    pub leading_tabs_removed: usize,
524}
525
526/// Resolve whole paragraphs for list formatting, within one body or table cell.
527/// Unlike deletion, this range may include a container's final newline.
528pub 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    // List formatting is body-only: it never selects a header, footer or footnote.
536    let (paragraphs, _) = paragraphs(document, &SegmentSelection::default())?;
537    let first = find(&paragraphs, from, match_case)?;
538    let last = if let Some(to) = to {
539        find(&paragraphs, to, match_case)?
540    } else {
541        first
542    };
543    let a = &paragraphs[first.paragraph];
544    let b = &paragraphs[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 &paragraphs[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); // omni-dev: coverage ignore-line reason="collect verified every paragraph has a final run ending at p.end, and the loop above leaves cursor at that run's end, so it always matches; kept as defence in depth on the write boundary"
578        }
579        // Structural elements can occupy no text index space, so contiguity
580        // alone is insufficient. The last paragraph is safe to format.
581        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
594/// Resolve formatting against the leased snapshot. Paragraph styling affects
595/// whole paragraphs, so validate and count their complete contents as well.
596pub 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        // Google may extend text styling to adjacent newlines. Validate any
607        // suggested newline at either edge even when it is outside the
608        // requested range; its styling must not bypass suggestion refusal.
609        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); // omni-dev: coverage ignore-line reason="collect refuses a paragraph whose last run does not end at the paragraph's end, and the loop above leaves cursor on the last run's end, so cursor always equals p.end here; kept as defence in depth on the write boundary"
649        }
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); // The final newline at 11 survives.
795    }
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        // Even a structural break occupying no additional index must refuse.
1127        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        // A gap between paragraphs remains unsafe.
1271        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    /// `resolve_delete` only walks the runs under the anchors, so paragraph
1307    /// styling re-checks that the whole paragraphs it widens to tile the range
1308    /// without overlapping — a server-side inconsistency must not be styled.
1309    #[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        // Unsuggested adjacent newlines remain valid.
1352        assert!(resolve_format(
1353            &doc(vec![paragraph(1, &["anchor", "\n"])]),
1354            "anchor",
1355            None,
1356            true,
1357            false
1358        )
1359        .is_ok());
1360    }
1361}