use std::collections::HashSet;
use regex::RegexBuilder;
use serde::Serialize;
use super::types::{Document, StructuralElement};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "reason", rename_all = "kebab-case")]
pub enum AnchorError {
InvalidAnchor,
InvalidInsertionText,
InvalidSelection,
SegmentNotFound,
AmbiguousSegment,
NotFound,
Ambiguous {
count: usize,
},
InvalidIndices,
SuggestedContent,
UnsafeRange,
UnorderedRange,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SegmentSelection {
pub segment_id: Option<String>,
pub tab_id: Option<String>,
}
impl SegmentSelection {
pub fn validate(&self) -> Result<(), AnchorError> {
if self.segment_id.as_ref().is_some_and(String::is_empty)
|| self.tab_id.as_ref().is_some_and(String::is_empty)
{
return Err(AnchorError::InvalidSelection);
}
if self.tab_id.is_some() && self.segment_id.is_none() {
return Err(AnchorError::InvalidSelection);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SegmentKind {
Header,
Footer,
Footnote,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Before,
After,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EditPreview {
pub start_index: i64,
pub end_index: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_kind: Option<SegmentKind>,
pub paragraphs: usize,
pub chars: usize,
pub bytes: usize,
}
struct Run<'a> {
text: &'a str,
start: i64,
end: i64,
suggested: bool,
}
struct Paragraph<'a> {
tab: usize,
tab_id: Option<String>,
container: usize,
start: i64,
end: i64,
protected_newline: bool,
runs: Vec<Run<'a>>,
}
#[derive(Clone, Copy)]
struct Match {
paragraph: usize,
start: i64,
end: i64,
suggested: bool,
}
fn utf16_len(text: &str) -> Result<i64, AnchorError> {
i64::try_from(text.encode_utf16().count()).map_err(|_| AnchorError::InvalidIndices)
}
fn paragraphs<'a>(
document: &'a Document,
selection: &SegmentSelection,
) -> Result<(Vec<Paragraph<'a>>, Option<SegmentKind>), AnchorError> {
selection.validate()?;
let mut out = Vec::new();
let mut selected = Vec::new();
let mut container = 0;
let mut tab_ids = HashSet::new();
for (tab, resolved) in document.resolved_tabs().iter().enumerate() {
if !document.tabs.is_empty() {
let id = resolved
.tab_id
.filter(|id| !id.is_empty())
.ok_or(AnchorError::InvalidIndices)?;
if !tab_ids.insert(id) {
return Err(AnchorError::InvalidIndices);
}
}
if let Some(id) = &selection.segment_id {
if selection
.tab_id
.as_deref()
.is_some_and(|tab| Some(tab) != resolved.tab_id)
{
continue;
}
for (kind, segments) in [
(SegmentKind::Header, resolved.headers()),
(SegmentKind::Footer, resolved.footers()),
(SegmentKind::Footnote, resolved.footnotes()),
] {
for segment in segments {
if segment.segment_id == id {
selected.push((tab, resolved.tab_id, kind, segment));
}
}
}
} else if let Some(body) = resolved.body {
collect(
&body.content,
tab,
resolved.tab_id,
false,
&mut container,
&mut out,
)?;
}
}
let mut segment_kind = None;
if selection.segment_id.is_some() {
let (tab, tab_id, kind, segment) = match selected.as_slice() {
[] => return Err(AnchorError::SegmentNotFound),
[one] => *one,
_ => return Err(AnchorError::AmbiguousSegment),
};
segment_kind = Some(kind);
collect(segment.content, tab, tab_id, true, &mut container, &mut out)?;
}
Ok((out, segment_kind))
}
fn collect<'a>(
elements: &'a [StructuralElement],
tab: usize,
tab_id: Option<&str>,
segment: bool,
next_container: &mut usize,
out: &mut Vec<Paragraph<'a>>,
) -> Result<(), AnchorError> {
let container = *next_container;
*next_container += 1;
for (position, element) in elements.iter().enumerate() {
if let Some(paragraph) = &element.paragraph {
let start = if segment {
element.start_index()
} else {
element.start_index.ok_or(AnchorError::InvalidIndices)?
};
let end = element.end_index.ok_or(AnchorError::InvalidIndices)?;
if start < i64::from(!segment) || end <= start {
return Err(AnchorError::InvalidIndices);
}
let mut runs = Vec::new();
let mut previous_end = start;
for inline in ¶graph.elements {
let run_start = if segment {
inline.start_index.unwrap_or(0)
} else {
inline.start_index.ok_or(AnchorError::InvalidIndices)?
};
let run_end = inline.end_index.ok_or(AnchorError::InvalidIndices)?;
if run_start < previous_end || run_end <= run_start || run_end > end {
return Err(AnchorError::InvalidIndices);
}
previous_end = run_end;
if let Some(text) = &inline.text_run {
if run_start.checked_add(utf16_len(&text.content)?) != Some(run_end) {
return Err(AnchorError::InvalidIndices);
}
runs.push(Run {
text: &text.content,
start: run_start,
end: run_end,
suggested: !text.suggested_insertion_ids.is_empty()
|| !text.suggested_deletion_ids.is_empty(),
});
}
}
if !runs
.last()
.is_some_and(|run| run.end == end && run.text.ends_with('\n'))
{
return Err(AnchorError::InvalidIndices);
}
let protected_newline = elements
.get(position + 1)
.is_none_or(|next| next.paragraph.is_none());
out.push(Paragraph {
tab,
tab_id: tab_id.map(str::to_owned),
container,
start,
end,
protected_newline,
runs,
});
} else if let Some(table) = &element.table {
for row in &table.table_rows {
for cell in &row.table_cells {
collect(&cell.content, tab, tab_id, segment, next_container, out)?;
}
}
}
}
Ok(())
}
fn find(
paragraphs: &[Paragraph<'_>],
needle: &str,
match_case: bool,
) -> Result<Match, AnchorError> {
if needle.is_empty() || needle.contains(['\n', '\r']) {
return Err(AnchorError::InvalidAnchor);
}
let pattern = RegexBuilder::new(®ex::escape(needle))
.case_insensitive(!match_case)
.build()
.map_err(|_| AnchorError::InvalidAnchor)?;
let mut count = 0;
let mut unique = None;
for (paragraph, p) in paragraphs.iter().enumerate() {
let mut first = 0;
while first < p.runs.len() {
let mut last = first + 1;
while last < p.runs.len() && p.runs[last - 1].end == p.runs[last].start {
last += 1;
}
let runs = &p.runs[first..last];
let text: String = runs.iter().map(|run| run.text).collect();
let mut offset = 0;
while let Some(found) = pattern.find_at(&text, offset) {
let start = map_boundary(runs, found.start())?;
let end = map_boundary(runs, found.end())?;
let suggested = runs
.iter()
.any(|run| run.suggested && run.start < end && run.end > start);
count += 1;
unique = Some(Match {
paragraph,
start,
end,
suggested,
});
offset = found.start()
+ text[found.start()..]
.chars()
.next()
.ok_or(AnchorError::InvalidAnchor)?
.len_utf8();
}
first = last;
}
}
match count {
0 => Err(AnchorError::NotFound),
1 => unique.ok_or(AnchorError::NotFound),
count => Err(AnchorError::Ambiguous { count }),
}
}
fn map_boundary(runs: &[Run<'_>], mut byte: usize) -> Result<i64, AnchorError> {
for run in runs {
if byte <= run.text.len() {
let prefix = run.text.get(..byte).ok_or(AnchorError::InvalidIndices)?;
return run
.start
.checked_add(utf16_len(prefix)?)
.ok_or(AnchorError::InvalidIndices);
}
byte -= run.text.len();
}
Err(AnchorError::InvalidIndices) }
pub(crate) fn insertion_counts(text: &str) -> (usize, usize) {
text.chars().filter(|ch| !matches!(ch, '\u{0000}'..='\u{0008}' | '\u{000c}'..='\u{001f}' | '\u{e000}'..='\u{f8ff}'))
.fold((0, 0), |(chars, bytes), ch| (chars + 1, bytes + ch.len_utf8()))
}
pub fn resolve_insert(
document: &Document,
anchor: &str,
side: Side,
text: &str,
match_case: bool,
) -> Result<EditPreview, AnchorError> {
resolve_insert_in(
document,
anchor,
side,
text,
match_case,
&SegmentSelection::default(),
)
}
pub fn resolve_insert_in(
document: &Document,
anchor: &str,
side: Side,
text: &str,
match_case: bool,
selection: &SegmentSelection,
) -> Result<EditPreview, AnchorError> {
let (chars, bytes) = insertion_counts(text);
if chars == 0 {
return Err(AnchorError::InvalidInsertionText);
}
let (paragraphs, segment_kind) = paragraphs(document, selection)?;
let found = find(¶graphs, anchor, match_case)?;
if found.suggested {
return Err(AnchorError::SuggestedContent);
}
let p = ¶graphs[found.paragraph];
let index = match side {
Side::Before => found.start,
Side::After => found.end,
};
if index < p.start || index >= p.end {
return Err(AnchorError::UnsafeRange); }
Ok(EditPreview {
start_index: index,
end_index: index,
tab_id: p.tab_id.clone(),
segment_id: selection.segment_id.clone(),
segment_kind,
paragraphs: 1,
chars,
bytes,
})
}
pub fn resolve_delete(
document: &Document,
from: &str,
to: Option<&str>,
match_case: bool,
) -> Result<EditPreview, AnchorError> {
resolve_delete_in(document, from, to, match_case, &SegmentSelection::default())
}
pub fn resolve_delete_in(
document: &Document,
from: &str,
to: Option<&str>,
match_case: bool,
selection: &SegmentSelection,
) -> Result<EditPreview, AnchorError> {
let (paragraphs, segment_kind) = paragraphs(document, selection)?;
let first = find(¶graphs, from, match_case)?;
let last = if let Some(to) = to {
find(¶graphs, to, match_case)?
} else {
first
};
let a = ¶graphs[first.paragraph];
let b = ¶graphs[last.paragraph];
if a.tab != b.tab
|| a.container != b.container
|| first.start > last.start
|| first.end > last.end
{
return Err(AnchorError::UnorderedRange);
}
let start = first.start;
let end = last.end;
let mut cursor = start;
let mut chars = 0;
let mut bytes = 0;
let mut count = 0;
for p in ¶graphs[first.paragraph..=last.paragraph] {
if p.tab != a.tab || p.container != a.container {
return Err(AnchorError::UnsafeRange); }
if p.protected_newline && start < p.end && end >= p.end {
return Err(AnchorError::UnsafeRange);
}
count += 1;
for run in &p.runs {
if run.end <= start || run.start >= end {
continue;
}
if run.start > cursor {
return Err(AnchorError::UnsafeRange);
}
if run.suggested {
return Err(AnchorError::SuggestedContent);
}
let lo = cursor.max(run.start);
let hi = end.min(run.end);
let mut index = run.start;
for ch in run.text.chars() {
let next = index + ch.len_utf16() as i64;
if index >= lo && next <= hi {
chars += 1;
bytes += ch.len_utf8();
} else if index < hi && next > lo {
return Err(AnchorError::InvalidIndices); }
index = next;
}
cursor = hi;
}
}
if cursor != end {
return Err(AnchorError::UnsafeRange); }
Ok(EditPreview {
start_index: start,
end_index: end,
tab_id: a.tab_id.clone(),
segment_id: selection.segment_id.clone(),
segment_kind,
paragraphs: count,
chars,
bytes,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ListPreview {
pub start_index: i64,
pub end_index: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
pub paragraphs: usize,
pub leading_tabs_removed: usize,
}
pub fn resolve_list(
document: &Document,
from: &str,
to: Option<&str>,
match_case: bool,
creating: bool,
) -> Result<ListPreview, AnchorError> {
let (paragraphs, _) = paragraphs(document, &SegmentSelection::default())?;
let first = find(¶graphs, from, match_case)?;
let last = if let Some(to) = to {
find(¶graphs, to, match_case)?
} else {
first
};
let a = ¶graphs[first.paragraph];
let b = ¶graphs[last.paragraph];
if first.paragraph > last.paragraph
|| a.tab != b.tab
|| a.container != b.container
|| first.start > last.start
|| first.end > last.end
{
return Err(AnchorError::UnorderedRange);
}
let mut cursor = a.start;
let mut leading_tabs_removed = 0;
for p in ¶graphs[first.paragraph..=last.paragraph] {
if p.tab != a.tab || p.container != a.container || p.start != cursor {
return Err(AnchorError::UnsafeRange);
}
let mut leading = creating;
for run in &p.runs {
if run.start != cursor {
return Err(AnchorError::UnsafeRange);
}
if run.suggested {
return Err(AnchorError::SuggestedContent);
}
for ch in run.text.chars() {
if leading && ch == '\t' {
leading_tabs_removed += 1;
} else {
leading = false;
}
}
cursor = run.end;
}
if cursor != p.end {
return Err(AnchorError::UnsafeRange); }
if p.protected_newline && p.end != b.end {
return Err(AnchorError::UnsafeRange);
}
}
Ok(ListPreview {
start_index: a.start,
end_index: b.end,
tab_id: a.tab_id.clone(),
paragraphs: last.paragraph - first.paragraph + 1,
leading_tabs_removed,
})
}
pub fn resolve_format(
document: &Document,
from: &str,
to: Option<&str>,
match_case: bool,
whole_paragraphs: bool,
) -> Result<EditPreview, AnchorError> {
let mut edit = resolve_delete(document, from, to, match_case)?;
let (paragraphs, _) = paragraphs(document, &SegmentSelection::default())?;
if !whole_paragraphs {
for p in paragraphs.iter().filter(|p| p.tab_id == edit.tab_id) {
for run in &p.runs {
if run.suggested
&& ((run.end == edit.start_index && run.text.ends_with('\n'))
|| (run.start == edit.end_index && run.text.starts_with('\n')))
{
return Err(AnchorError::SuggestedContent);
}
}
}
return Ok(edit);
}
let affected: Vec<_> = paragraphs
.iter()
.filter(|p| {
p.tab_id == edit.tab_id && p.start.max(edit.start_index) < p.end.min(edit.end_index)
})
.collect();
let first = affected.first().ok_or(AnchorError::InvalidIndices)?;
let last = affected.last().ok_or(AnchorError::InvalidIndices)?;
let mut cursor = first.start;
edit.chars = 0;
edit.bytes = 0;
for p in &affected {
if p.container != first.container || p.start != cursor {
return Err(AnchorError::UnsafeRange);
}
for run in &p.runs {
if run.start != cursor {
return Err(AnchorError::UnsafeRange);
}
if run.suggested {
return Err(AnchorError::SuggestedContent);
}
edit.chars += run.text.chars().count();
edit.bytes += run.text.len();
cursor = run.end;
}
if cursor != p.end {
return Err(AnchorError::UnsafeRange); }
}
edit.start_index = first.start;
edit.end_index = last.end;
edit.paragraphs = affected.len();
Ok(edit)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use proptest::prelude::*;
use serde_json::{json, Value};
fn paragraph(start: i64, runs: &[&str]) -> Value {
let mut index = start;
let elements: Vec<Value> = runs
.iter()
.map(|text| {
let end = index + text.encode_utf16().count() as i64;
let element =
json!({"startIndex": index, "endIndex": end, "textRun": {"content": text}});
index = end;
element
})
.collect();
json!({"startIndex": start, "endIndex": index, "paragraph": {"elements": elements}})
}
fn doc(content: Vec<Value>) -> Document {
serde_json::from_value(json!({"body": {"content": content}})).unwrap()
}
fn selection(id: &str) -> SegmentSelection {
SegmentSelection {
segment_id: Some(id.into()),
tab_id: None,
}
}
#[test]
fn all_segment_families_use_zero_based_utf16_and_ignore_other_content() {
for (map, kind) in [
("headers", SegmentKind::Header),
("footers", SegmentKind::Footer),
("footnotes", SegmentKind::Footnote),
] {
let mut p = paragraph(0, &["😀 a", "nchor\n"]);
p.as_object_mut().unwrap().remove("startIndex");
p["paragraph"]["elements"][0]
.as_object_mut()
.unwrap()
.remove("startIndex");
let document: Document = serde_json::from_value(json!({
"body": {"content": [paragraph(1, &["anchor anchor\n"])]},
map: {"s": {"content": [p]}, "other": {"content": [{"paragraph": {}}]}}
}))
.unwrap();
let edit = resolve_delete_in(&document, "anchor", None, true, &selection("s")).unwrap();
assert_eq!(
(edit.start_index, edit.end_index, edit.chars, edit.bytes),
(3, 9, 6, 6)
);
assert_eq!(edit.segment_id.as_deref(), Some("s"));
assert_eq!(edit.segment_kind, Some(kind));
assert_eq!(
resolve_insert_in(&document, "😀", Side::Before, "x", true, &selection("s"))
.unwrap()
.start_index,
0
);
assert_eq!(
resolve_delete(&document, "anchor", None, true),
Err(AnchorError::Ambiguous { count: 2 })
);
}
}
#[test]
fn segment_identity_is_resolved_before_anchor_uniqueness_in_child_tabs() {
let document: Document = serde_json::from_value(json!({"tabs": [{
"tabProperties": {"tabId": "parent"},
"documentTab": {"headers": {"s": {"content": [paragraph(0, &["other\n"])]}}},
"childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": {"footnotes": {"s": {"content": [paragraph(0, &["anchor\n"])]}}}}]
}]})).unwrap();
assert_eq!(
resolve_delete_in(&document, "anchor", None, true, &selection("s")),
Err(AnchorError::AmbiguousSegment)
);
let mut selected = selection("s");
selected.tab_id = Some("child".into());
let edit = resolve_delete_in(&document, "anchor", None, true, &selected).unwrap();
assert_eq!(edit.tab_id.as_deref(), Some("child"));
assert_eq!(edit.segment_kind, Some(SegmentKind::Footnote));
selected.tab_id = Some("missing".into());
assert_eq!(
resolve_delete_in(&document, "anchor", None, true, &selected),
Err(AnchorError::SegmentNotFound)
);
for invalid in [
selection(""),
SegmentSelection {
segment_id: None,
tab_id: Some("child".into()),
},
SegmentSelection {
segment_id: Some("s".into()),
tab_id: Some(String::new()),
},
] {
assert_eq!(
resolve_delete_in(&document, "anchor", None, true, &invalid),
Err(AnchorError::InvalidSelection)
);
}
}
#[test]
fn selected_segments_preserve_suggestion_and_structural_guards() {
let mut suggested = paragraph(0, &["anchor\n"]);
suggested["paragraph"]["elements"][0]["textRun"]["suggestedDeletionIds"] =
json!(["suggestion"]);
let document: Document =
serde_json::from_value(json!({"headers": {"s": {"content": [suggested]}}})).unwrap();
assert_eq!(
resolve_delete_in(&document, "anchor", None, true, &selection("s")),
Err(AnchorError::SuggestedContent)
);
assert_eq!(
resolve_insert_in(&document, "anchor", Side::After, "x", true, &selection("s")),
Err(AnchorError::SuggestedContent)
);
let document: Document = serde_json::from_value(json!({"footers": {"s": {"content": [
paragraph(0, &["first\n"]), {"startIndex": 6, "endIndex": 7, "sectionBreak": {}}, paragraph(7, &["last\n"])
]}}})).unwrap();
assert_eq!(
resolve_delete_in(&document, "first", Some("last"), true, &selection("s")),
Err(AnchorError::UnsafeRange)
);
assert_eq!(
resolve_delete_in(&document, "last\n", None, true, &selection("s")),
Err(AnchorError::InvalidAnchor)
);
let edit = resolve_delete_in(&document, "last", None, true, &selection("s")).unwrap();
assert_eq!(edit.end_index, 11); }
#[test]
fn segment_table_cells_and_inline_gaps_cannot_be_crossed() {
let gap = json!({"endIndex": 4, "paragraph": {"elements": [
{"endIndex": 1, "textRun": {"content": "a"}},
{"startIndex": 1, "endIndex": 2, "footnoteReference": {"footnoteId": "note"}},
{"startIndex": 2, "endIndex": 4, "textRun": {"content": "b\n"}}
]}});
let document: Document =
serde_json::from_value(json!({"footnotes": {"s": {"content": [gap]}}})).unwrap();
assert_eq!(
resolve_delete_in(&document, "ab", None, true, &selection("s")),
Err(AnchorError::NotFound)
);
assert_eq!(
resolve_delete_in(&document, "a", Some("b"), true, &selection("s")),
Err(AnchorError::UnsafeRange)
);
let document: Document = serde_json::from_value(json!({"headers": {"s": {"content": [
paragraph(0, &["before\n"]), {"startIndex": 7, "endIndex": 30, "table": {"tableRows": [{"tableCells": [
{"content": [paragraph(9, &["first\n"])]}, {"content": [paragraph(20, &["last\n"])]}
]}]}}
]}}})).unwrap();
assert_eq!(
resolve_delete_in(&document, "first", Some("last"), true, &selection("s")),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_delete_in(&document, "before", Some("first"), true, &selection("s")),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_delete_in(&document, "first", None, true, &selection("s"))
.unwrap()
.end_index,
14
);
}
#[test]
fn astral_characters_and_split_runs_use_server_utf16_indices() {
let document = doc(vec![paragraph(1, &["😀 a", "nchor\n"])]);
let edit = resolve_insert(&document, "anchor", Side::After, "x", true).unwrap();
assert_eq!((edit.start_index, edit.end_index), (10, 10));
let edit = resolve_delete(&document, "anchor", None, true).unwrap();
assert_eq!((edit.start_index, edit.end_index, edit.chars), (4, 10, 6));
}
#[test]
fn unicode_case_folding_preserves_original_boundaries_and_matches_literals() {
let document = doc(vec![paragraph(1, &["😀 \u{212a}.[İ]\n"])]);
let edit = resolve_delete(&document, "k.[İ]", None, false).unwrap();
assert_eq!(
(edit.start_index, edit.end_index, edit.chars, edit.bytes),
(4, 9, 5, 8)
);
assert_eq!(
resolve_delete(&document, "k.[İ]", None, true),
Err(AnchorError::NotFound)
);
}
#[test]
fn overlapping_matches_are_ambiguous_and_paragraph_breaks_are_not_anchors() {
let document = doc(vec![paragraph(1, &["aaa\n"])]);
assert_eq!(
resolve_delete(&document, "aa", None, true),
Err(AnchorError::Ambiguous { count: 2 })
);
assert_eq!(
resolve_delete(&document, "a\na", None, true),
Err(AnchorError::InvalidAnchor)
);
assert_eq!(
resolve_delete(&document, "", None, true),
Err(AnchorError::InvalidAnchor)
);
assert_eq!(
resolve_delete(&document, "absent", None, true),
Err(AnchorError::NotFound)
);
}
#[test]
fn inline_objects_do_not_shift_later_anchors_or_manufacture_matches() {
let document = doc(vec![
json!({"startIndex": 1, "endIndex": 5, "paragraph": {"elements": [
{"startIndex": 1, "endIndex": 2, "textRun": {"content": "a"}},
{"startIndex": 2, "endIndex": 3, "inlineObjectElement": {"inlineObjectId": "img"}},
{"startIndex": 3, "endIndex": 5, "textRun": {"content": "b\n"}}
]}}),
]);
assert_eq!(
resolve_insert(&document, "b", Side::Before, "x", true)
.unwrap()
.start_index,
3
);
assert_eq!(
resolve_delete(&document, "ab", None, true),
Err(AnchorError::NotFound)
);
assert_eq!(
resolve_delete(&document, "a", Some("b"), true),
Err(AnchorError::UnsafeRange)
);
}
#[test]
fn incomplete_or_inconsistent_indices_are_refused() {
let mut p = paragraph(1, &["anchor\n"]);
p["paragraph"]["elements"][0]
.as_object_mut()
.unwrap()
.remove("startIndex");
assert_eq!(
resolve_delete(&doc(vec![p]), "anchor", None, true),
Err(AnchorError::InvalidIndices)
);
let mut p = paragraph(1, &["😀 anchor\n"]);
p["paragraph"]["elements"][0]["endIndex"] = json!(8);
assert_eq!(
resolve_delete(&doc(vec![p]), "anchor", None, true),
Err(AnchorError::InvalidIndices)
);
}
#[test]
fn paragraph_and_run_indices_that_do_not_nest_are_refused() {
type Mutation = fn(&mut Value);
let cases: [(&str, Mutation); 7] = [
("paragraph starts before the body", |p| {
p["startIndex"] = json!(0);
}),
("paragraph is empty", |p| {
p["endIndex"] = json!(1);
}),
("run overlaps the previous run", |p| {
p["paragraph"]["elements"][1]["startIndex"] = json!(2);
}),
("run is empty", |p| {
p["paragraph"]["elements"][1]["endIndex"] = json!(4);
}),
("run overruns its paragraph", |p| {
p["endIndex"] = json!(7);
}),
("last run is not newline-terminated", |p| {
p["paragraph"]["elements"][1]["textRun"]["content"] = json!("def");
p["paragraph"]["elements"][1]["endIndex"] = json!(7);
p["endIndex"] = json!(7);
}),
("last run stops short of the paragraph end", |p| {
p["endIndex"] = json!(9);
}),
];
for (name, mutate) in cases {
let mut p = paragraph(1, &["abc", "def\n"]);
mutate(&mut p);
assert_eq!(
resolve_delete(&doc(vec![p]), "abc", None, true),
Err(AnchorError::InvalidIndices),
"{name}"
);
}
let mut p = paragraph(1, &["abc\n"]);
p["paragraph"]["elements"] = json!([]);
assert_eq!(
resolve_delete(&doc(vec![p]), "abc", None, true),
Err(AnchorError::InvalidIndices),
"a paragraph with no runs"
);
}
#[test]
fn suggestions_in_anchors_and_inside_ranges_are_refused() {
for field in ["suggestedInsertionIds", "suggestedDeletionIds"] {
let mut p = paragraph(1, &["start ", "pending", " finish\n"]);
p["paragraph"]["elements"][1]["textRun"][field] = json!(["suggestion"]);
let document = doc(vec![p]);
assert_eq!(
resolve_insert(&document, "pending", Side::After, "x", true),
Err(AnchorError::SuggestedContent)
);
assert_eq!(
resolve_delete(&document, "start", Some("finish"), true),
Err(AnchorError::SuggestedContent)
);
assert!(resolve_delete(&document, "start", None, true).is_ok());
}
}
#[test]
fn safe_ranges_span_paragraphs_and_preserve_the_last_newline() {
let document = doc(vec![paragraph(1, &["start\n"]), paragraph(7, &["end\n"])]);
let edit = resolve_delete(&document, "start", Some("end"), true).unwrap();
assert_eq!(
(
edit.start_index,
edit.end_index,
edit.chars,
edit.paragraphs
),
(1, 10, 9, 2)
);
assert_eq!(
resolve_delete(&document, "end", Some("start"), true),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_insert(&document, "end", Side::After, "x", true)
.unwrap()
.start_index,
10
);
assert_eq!(
resolve_insert(&document, "end\n", Side::After, "x", true),
Err(AnchorError::InvalidAnchor)
);
}
#[test]
fn tables_cells_and_tabs_have_independent_boundaries() {
let table = json!({"startIndex": 8, "endIndex": 30, "table": {"tableRows": [{"tableCells": [
{"content": [paragraph(11, &["cell\n"])]},
{"content": [paragraph(18, &["other\n"])]}
]}]}});
let document = doc(vec![
paragraph(1, &["before\n"]),
table,
paragraph(30, &["after\n"]),
]);
assert_eq!(
resolve_insert(&document, "cell", Side::After, "x", true)
.unwrap()
.start_index,
15
);
assert_eq!(
resolve_delete(&document, "cell", Some("other"), true),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_delete(&document, "before", Some("after"), true),
Err(AnchorError::UnsafeRange)
);
let tabbed: Document = serde_json::from_value(json!({"tabs": [
{"tabProperties": {"tabId": "t1"}, "documentTab": {"body": {"content": [paragraph(1, &["start\n"])]}},
"childTabs": [{"tabProperties": {"tabId": "t2"}, "documentTab": {"body": {"content": [paragraph(1, &["end\n"])]}}}]}
]})).unwrap();
assert_eq!(
resolve_insert(&tabbed, "end", Side::Before, "x", true)
.unwrap()
.tab_id
.as_deref(),
Some("t2")
);
assert_eq!(
resolve_delete(&tabbed, "start", Some("end"), true),
Err(AnchorError::UnorderedRange)
);
}
#[test]
fn non_body_and_table_of_contents_text_is_not_addressable() {
let document: Document = serde_json::from_value(json!({
"headers": {"h1": {"content": [paragraph(1, &["header\n"])]}},
"body": {"content": [{"tableOfContents": {"content": [paragraph(1, &["toc\n"])]}}]}
}))
.unwrap();
for needle in ["header", "toc"] {
assert_eq!(
resolve_delete(&document, needle, None, true),
Err(AnchorError::NotFound)
);
}
}
#[test]
fn lists_expand_unicode_anchors_to_whole_paragraphs_and_count_split_leading_tabs() {
let document = doc(vec![
paragraph(1, &["\t", "\t😀 K", "elvin\n"]),
paragraph(13, &["\tlast\n"]),
paragraph(19, &["untouched\n"]),
]);
let edit = resolve_list(&document, "kelvin", Some("last"), false, true).unwrap();
assert_eq!(
(
edit.start_index,
edit.end_index,
edit.paragraphs,
edit.leading_tabs_removed
),
(1, 19, 2, 3)
);
let single = resolve_list(&document, "last", None, true, false).unwrap();
assert_eq!(
(
single.start_index,
single.end_index,
single.leading_tabs_removed
),
(13, 19, 0)
);
assert_eq!(
resolve_list(&document, "last", Some("Kelvin"), true, true),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_list(&document, "Kelvin", None, false, true)
.unwrap()
.end_index,
13
);
}
#[test]
fn list_formatting_refuses_objects_gaps_structures_and_suggestions_outside_anchor() {
let object = json!({"startIndex": 1, "endIndex": 5, "paragraph": {"elements": [
{"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "img"}},
{"startIndex": 2, "endIndex": 5, "textRun": {"content": "ok\n"}}
]}});
assert_eq!(
resolve_list(&doc(vec![object]), "ok", None, true, true),
Err(AnchorError::UnsafeRange)
);
let gap = doc(vec![paragraph(1, &["first\n"]), paragraph(8, &["last\n"])]);
assert_eq!(
resolve_list(&gap, "first", Some("last"), true, true),
Err(AnchorError::UnsafeRange)
);
let structure = doc(vec![
paragraph(1, &["first\n"]),
json!({"sectionBreak": {}}),
paragraph(7, &["last\n"]),
]);
assert_eq!(
resolve_list(&structure, "first", Some("last"), true, true),
Err(AnchorError::UnsafeRange)
);
for creating in [true, false] {
for field in ["suggestedInsertionIds", "suggestedDeletionIds"] {
let mut p = paragraph(1, &["\t", "anchor", " pending\n"]);
p["paragraph"]["elements"][0]["textRun"][field] = json!(["s"]);
assert_eq!(
resolve_list(&doc(vec![p]), "anchor", None, true, creating),
Err(AnchorError::SuggestedContent)
);
}
}
let out_of_order = doc(vec![
paragraph(10, &["later\n"]),
paragraph(1, &["earlier\n"]),
]);
assert_eq!(
resolve_list(&out_of_order, "earlier", Some("later"), true, true),
Err(AnchorError::UnorderedRange)
);
let mut invalid = paragraph(1, &["anchor\n"]);
invalid["endIndex"] = json!(9);
assert_eq!(
resolve_list(&doc(vec![invalid]), "anchor", None, true, true),
Err(AnchorError::InvalidIndices)
);
}
#[test]
fn lists_respect_table_cells_and_nested_tab_identity_and_final_newlines() {
let table = json!({"table": {"tableRows": [{"tableCells": [
{"content": [paragraph(11, &["cell\n"])]},
{"content": [paragraph(18, &["other\n"])]}
]}]}});
let document = doc(vec![
paragraph(1, &["before\n"]),
table,
paragraph(30, &["after\n"]),
]);
assert_eq!(
resolve_list(&document, "cell", None, true, true)
.unwrap()
.end_index,
16
);
assert_eq!(
resolve_list(&document, "cell", Some("other"), true, false),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_list(&document, "before", Some("after"), true, true),
Err(AnchorError::UnsafeRange)
);
let tabbed: Document = serde_json::from_value(json!({"tabs": [
{"tabProperties": {"tabId": "t1"}, "documentTab": {"body": {"content": [paragraph(1, &["first\n"])]}},
"childTabs": [{"tabProperties": {"tabId": "t2"}, "documentTab": {"body": {"content": [paragraph(1, &["last\n"])]}}}]}
]})).unwrap();
let edit = resolve_list(&tabbed, "last", None, true, true).unwrap();
assert_eq!(edit.tab_id.as_deref(), Some("t2"));
assert_eq!((edit.start_index, edit.end_index), (1, 6));
assert_eq!(
resolve_list(&tabbed, "first", Some("last"), true, true),
Err(AnchorError::UnorderedRange)
);
assert_eq!(
resolve_list(&doc(vec![paragraph(1, &["aaa\n"])]), "aa", None, true, true),
Err(AnchorError::Ambiguous { count: 2 })
);
}
proptest! {
#[test]
fn random_unicode_prefixes_and_run_splits_preserve_utf16_offsets(
prefix in prop::collection::vec(prop_oneof![Just('😀'), Just('𠀀'), Just('é'), Just('x')], 0..80),
split in 0usize..100,
) {
let prefix: String = prefix.into_iter().collect();
let text = format!("{prefix}ANCHOR\n");
let boundaries: Vec<usize> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
let byte = boundaries[split % boundaries.len()];
let runs: Vec<&str> = [&text[..byte], &text[byte..]].into_iter().filter(|s| !s.is_empty()).collect();
let document = doc(vec![paragraph(1, &runs)]);
let edit = resolve_delete(&document, "ANCHOR", None, true).unwrap();
prop_assert_eq!(edit.start_index, 1 + prefix.encode_utf16().count() as i64);
prop_assert_eq!(edit.end_index, edit.start_index + 6);
let list = resolve_list(&document, "ANCHOR", None, true, true).unwrap();
prop_assert_eq!(list.start_index, 1);
prop_assert_eq!(list.end_index, 1 + text.encode_utf16().count() as i64);
prop_assert_eq!(list.paragraphs, 1);
}
}
#[test]
fn insertion_counts_exclude_characters_stripped_by_google() {
let document = doc(vec![paragraph(1, &["anchor\n"])]);
let edit =
resolve_insert(&document, "anchor", Side::After, "\0😀\r\u{e000}x\n", true).unwrap();
assert_eq!((edit.chars, edit.bytes), (3, 6));
assert_eq!(
resolve_insert(&document, "anchor", Side::After, "\0\r", true),
Err(AnchorError::InvalidInsertionText)
);
}
#[test]
fn missing_empty_or_duplicate_tab_ids_are_refused() {
for ids in [vec![None], vec![Some("")], vec![Some("same"), Some("same")]] {
let tabs: Vec<Value> = ids.into_iter().map(|id| json!({
"tabProperties": {"tabId": id}, "documentTab": {"body": {"content": [paragraph(1, &["anchor\n"])]}}
})).collect();
let document: Document = serde_json::from_value(json!({"tabs": tabs})).unwrap();
assert_eq!(
resolve_insert(&document, "anchor", Side::Before, "x", true),
Err(AnchorError::InvalidIndices)
);
assert_eq!(
resolve_list(&document, "anchor", None, true, true),
Err(AnchorError::InvalidIndices)
);
}
}
#[test]
fn formatting_resolves_utf16_and_expands_whole_paragraph_effects() {
let document = doc(vec![
paragraph(1, &["😀 pre ", "anchor tail\n"]),
paragraph(21, &["end\n"]),
]);
let text = resolve_format(&document, "anchor", None, true, false).unwrap();
assert_eq!(
(text.start_index, text.end_index, text.chars, text.bytes),
(8, 14, 6, 6)
);
let p = resolve_format(&document, "anchor", None, true, true).unwrap();
assert_eq!(
(p.start_index, p.end_index, p.chars, p.bytes, p.paragraphs),
(1, 20, 18, 21, 1)
);
assert!(resolve_format(&document, "anchor", Some("end"), true, true).is_err());
let document = doc(vec![
paragraph(1, &["😀 pre ", "anchor tail\n"]),
paragraph(20, &["end\n"]),
]);
let p = resolve_format(&document, "anchor", Some("end"), true, true).unwrap();
assert_eq!(
(p.start_index, p.end_index, p.paragraphs, p.chars, p.bytes),
(1, 24, 2, 22, 25)
);
}
#[test]
fn paragraph_formatting_refuses_suggestions_and_objects_outside_anchors() {
let mut p = paragraph(1, &["prefix ", "anchor\n"]);
p["paragraph"]["elements"][0]["textRun"]["suggestedInsertionIds"] = json!(["pending"]);
let document = doc(vec![p]);
assert!(resolve_format(&document, "anchor", None, true, false).is_ok());
assert_eq!(
resolve_format(&document, "anchor", None, true, true),
Err(AnchorError::SuggestedContent)
);
let document = doc(vec![
json!({"startIndex": 1, "endIndex": 9, "paragraph": {"elements": [
{"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "img"}},
{"startIndex": 2, "endIndex": 9, "textRun": {"content": "anchor\n"}}
]}}),
]);
assert!(resolve_format(&document, "anchor", None, true, false).is_ok());
assert_eq!(
resolve_format(&document, "anchor", None, true, true),
Err(AnchorError::UnsafeRange)
);
}
#[test]
fn paragraph_formatting_refuses_paragraphs_whose_indices_overlap() {
let document = doc(vec![
paragraph(1, &["aaaaaa\n"]),
paragraph(5, &["bbbbbb\n"]),
]);
assert!(resolve_delete(&document, "aaaaaa", Some("bbbbbb"), true).is_ok());
assert!(resolve_format(&document, "aaaaaa", Some("bbbbbb"), true, false).is_ok());
assert_eq!(
resolve_format(&document, "aaaaaa", Some("bbbbbb"), true, true),
Err(AnchorError::UnsafeRange)
);
}
#[test]
fn paragraph_formatting_preserves_nested_tab_identity() {
let document: Document = serde_json::from_value(json!({"tabs": [{"tabProperties": {"tabId": "parent"}, "childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": {"body": {"content": [paragraph(1, &["😀 anchor\n"])]}}}]}]})).unwrap();
let p = resolve_format(&document, "anchor", None, true, true).unwrap();
assert_eq!(p.tab_id.as_deref(), Some("child"));
assert_eq!((p.start_index, p.end_index), (1, 11));
}
#[test]
fn text_formatting_refuses_suggested_adjacent_newlines() {
let mut p = paragraph(1, &["anchor", "\n"]);
p["paragraph"]["elements"][1]["textRun"]["suggestedDeletionIds"] = json!(["pending"]);
assert_eq!(
resolve_format(&doc(vec![p]), "anchor", None, true, false),
Err(AnchorError::SuggestedContent)
);
let mut preceding = paragraph(1, &["before", "\n"]);
preceding["paragraph"]["elements"][1]["textRun"]["suggestedInsertionIds"] =
json!(["pending"]);
assert_eq!(
resolve_format(
&doc(vec![preceding, paragraph(8, &["anchor\n"])]),
"anchor",
None,
true,
false
),
Err(AnchorError::SuggestedContent)
);
assert!(resolve_format(
&doc(vec![paragraph(1, &["anchor", "\n"])]),
"anchor",
None,
true,
false
)
.is_ok());
}
}