Skip to main content

omni_dev/drive/docs/
structure.rs

1//! Flattening a document's structural tree into an index-ordered list.
2//!
3//! Pure and network-free, the way `crate::drive::sheets::a1` is: this is the
4//! module where the subtle bugs live, so it must be testable without a
5//! wiremock server. `body.content[]` is a recursive forest — a table holds
6//! rows, which hold cells, which hold more structural elements, including
7//! more tables — and both `drive docs info` and `drive docs read` need it
8//! walked exactly once, the same way.
9//!
10//! Two rules govern the walk and both matter:
11//!
12//! - **A container is emitted before its contents.** A table yields one
13//!   [`ElementKind::Table`] element carrying the table's own `[start, end)`
14//!   — which is what addresses the whole table — followed by each cell's
15//!   paragraphs. Without the container, a table is invisible in the flat
16//!   list except as an unexplained index gap.
17//! - **Text is trimmed of its paragraph terminator; indices never are.**
18//!   Every Docs paragraph's final `textRun` ends in `\n`, and that newline
19//!   occupies one index unit. Rendering it would put a blank line after
20//!   every row; subtracting it from `end_index` would make the reported
21//!   indices lie about the document. So exactly one of the two is adjusted.
22//!
23//! This module deliberately **computes no index of its own** — it only
24//! reports what the server sent. Docs indices are UTF-16 code units and Rust
25//! strings are UTF-8, so any index derived here would be wrong for
26//! astral-plane characters in a way that corrupts silently rather than
27//! failing. The write path avoids the problem the same way, by addressing
28//! insertions with `endOfSegmentLocation` rather than a number.
29
30use serde::Serialize;
31
32use crate::drive::docs::types::{Body, ResolvedTab, StructuralElement};
33
34/// What kind of thing a flattened element is.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum ElementKind {
38    /// A paragraph, possibly a heading or a list item.
39    Paragraph,
40    /// A table container. Its cells' paragraphs follow it.
41    Table,
42    /// A section break.
43    SectionBreak,
44    /// A generated table of contents. Its entries follow it.
45    TableOfContents,
46}
47
48impl ElementKind {
49    /// The kebab-case label, matching the serde wire form.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Paragraph => "paragraph",
54            Self::Table => "table",
55            Self::SectionBreak => "section-break",
56            Self::TableOfContents => "table-of-contents",
57        }
58    }
59}
60
61/// One structural element, flattened out of the document tree.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct DocElement {
64    /// Inclusive start, in UTF-16 code units, within this tab's body segment.
65    pub start_index: i64,
66    /// Exclusive end, likewise.
67    pub end_index: i64,
68    /// Which kind of element this is.
69    pub kind: ElementKind,
70    /// Structural address: `[3]` is the 4th top-level element, `[3, 0, 1, 0]`
71    /// the 1st element of row 0, cell 1 of that table. `path.len() - 1` is
72    /// the nesting depth, which is what a renderer indents by.
73    pub path: Vec<usize>,
74    /// A paragraph's `namedStyleType` (`HEADING_1`, `NORMAL_TEXT`, …).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub style: Option<String>,
77    /// A list item's `listId`.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub list_id: Option<String>,
80    /// A table's row count.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub rows: Option<i64>,
83    /// A table's column count.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub columns: Option<i64>,
86    /// A paragraph's text, with its terminating newline removed. Empty for
87    /// every non-paragraph kind.
88    pub text: String,
89}
90
91impl DocElement {
92    /// Nesting depth: `0` for a top-level element.
93    #[must_use]
94    pub fn depth(&self) -> usize {
95        self.path.len().saturating_sub(1)
96    }
97}
98
99/// Walks `body.content` depth-first, emitting every structural element in
100/// document-index order, containers before their contents.
101#[must_use]
102pub fn flatten(body: &Body) -> Vec<DocElement> {
103    flatten_content(&body.content)
104}
105
106/// Like [`flatten`], but over a bare content slice rather than a [`Body`].
107///
108/// This is what a header, footer or footnote segment needs, since each is
109/// the same `content: Vec<StructuralElement>` shape without the `Body`
110/// wrapper.
111#[must_use]
112pub fn flatten_content(content: &[StructuralElement]) -> Vec<DocElement> {
113    let mut out = Vec::new();
114    walk(content, &mut Vec::new(), &mut out);
115    out
116}
117
118/// Recursive half of [`flatten`]. `path` is the address stack, pushed and
119/// popped around each descent.
120fn walk(content: &[StructuralElement], path: &mut Vec<usize>, out: &mut Vec<DocElement>) {
121    for (index, element) in content.iter().enumerate() {
122        path.push(index);
123        emit(element, path, out);
124        path.pop();
125    }
126}
127
128/// Emits one element, then descends into it if it is a container.
129fn emit(element: &StructuralElement, path: &mut Vec<usize>, out: &mut Vec<DocElement>) {
130    let base = |kind: ElementKind| DocElement {
131        start_index: element.start_index(),
132        end_index: element.end_index(),
133        kind,
134        path: path.clone(),
135        style: None,
136        list_id: None,
137        rows: None,
138        columns: None,
139        text: String::new(),
140    };
141
142    if let Some(paragraph) = &element.paragraph {
143        out.push(DocElement {
144            style: paragraph.named_style_type().map(ToString::to_string),
145            list_id: paragraph.bullet.as_ref().and_then(|b| b.list_id.clone()),
146            text: strip_paragraph_terminator(&paragraph.text()),
147            ..base(ElementKind::Paragraph)
148        });
149        return;
150    }
151
152    if let Some(table) = &element.table {
153        out.push(DocElement {
154            rows: table.rows,
155            columns: table.columns,
156            ..base(ElementKind::Table)
157        });
158        for (row_index, row) in table.table_rows.iter().enumerate() {
159            path.push(row_index);
160            for (cell_index, cell) in row.table_cells.iter().enumerate() {
161                path.push(cell_index);
162                walk(&cell.content, path, out);
163                path.pop();
164            }
165            path.pop();
166        }
167        return;
168    }
169
170    if let Some(toc) = &element.table_of_contents {
171        out.push(base(ElementKind::TableOfContents));
172        walk(&toc.content, path, out);
173        return;
174    }
175
176    if element.section_break.is_some() {
177        out.push(base(ElementKind::SectionBreak));
178    }
179    // An element matching none of the above is a variant this crate does not
180    // model. Dropping it is deliberate: emitting a kindless row would be
181    // noise, and inventing a kind for it would be a guess.
182}
183
184/// Removes the single trailing `\n` every Docs paragraph carries.
185///
186/// Exactly one newline, not a `trim_end`: a paragraph whose text genuinely
187/// ends in a soft line break (`\v`) or trailing spaces keeps them, because
188/// they are content the user typed.
189fn strip_paragraph_terminator(text: &str) -> String {
190    text.strip_suffix('\n').unwrap_or(text).to_string()
191}
192
193/// A heading paragraph, for the `docs info` outline.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct Heading {
196    /// The `namedStyleType` that made it a heading.
197    pub style: String,
198    /// Inclusive start, in UTF-16 code units.
199    pub start_index: i64,
200    /// Exclusive end, likewise.
201    pub end_index: i64,
202    /// The heading's text, terminator stripped.
203    pub text: String,
204}
205
206/// Aggregate counts and the heading outline for one tab.
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
208pub struct TabOutline {
209    /// The tab's id, absent for a legacy single-body document.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub tab_id: Option<String>,
212    /// The tab's title, likewise.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub title: Option<String>,
215    /// Depth in the tab tree.
216    pub nesting_level: i64,
217    /// The tab's length in UTF-16 code units — the highest `endIndex` seen,
218    /// and so the upper bound on any valid index within it.
219    pub characters: i64,
220    /// How many paragraphs it contains, at every nesting depth.
221    pub paragraphs: usize,
222    /// How many tables.
223    pub tables: usize,
224    /// How many section breaks.
225    pub section_breaks: usize,
226    /// How many generated tables of contents.
227    pub tables_of_contents: usize,
228    /// Every heading, in document order.
229    pub headings: Vec<Heading>,
230}
231
232/// Whether a `namedStyleType` denotes a heading for outline purposes.
233///
234/// `TITLE` and `SUBTITLE` count: they are what a document's top-level
235/// structure actually uses, and omitting them makes the outline of a
236/// well-formed document start at its second section.
237fn is_heading_style(style: &str) -> bool {
238    style.starts_with("HEADING_") || style == "TITLE" || style == "SUBTITLE"
239}
240
241/// Counts and outlines one tab.
242///
243/// **Body only.** Unlike `docs read` (issue #1799), this does not walk
244/// `tab.headers()`/`.footers()`/`.footnotes()` — a heading inside a header,
245/// footer or footnote is invisible to `docs info`'s outline and does not
246/// contribute to its counts, even though `docs read` on the same document
247/// now surfaces that content. Folding segments into the outline is
248/// unstarted follow-up work, not a deliberately-kept gap the way
249/// `write.rs`'s dry-run count is.
250#[must_use]
251pub fn outline(tab: &ResolvedTab<'_>) -> TabOutline {
252    let mut out = TabOutline {
253        tab_id: tab.tab_id.map(ToString::to_string),
254        title: tab.title.map(ToString::to_string),
255        nesting_level: tab.nesting_level,
256        ..TabOutline::default()
257    };
258    let Some(body) = tab.body else {
259        return out;
260    };
261    for element in flatten(body) {
262        out.characters = out.characters.max(element.end_index);
263        match element.kind {
264            ElementKind::Paragraph => {
265                out.paragraphs += 1;
266                if let Some(style) = element.style.as_deref().filter(|s| is_heading_style(s)) {
267                    out.headings.push(Heading {
268                        style: style.to_string(),
269                        start_index: element.start_index,
270                        end_index: element.end_index,
271                        text: element.text.clone(),
272                    });
273                }
274            }
275            ElementKind::Table => out.tables += 1,
276            ElementKind::SectionBreak => out.section_breaks += 1,
277            ElementKind::TableOfContents => out.tables_of_contents += 1,
278        }
279    }
280    out
281}
282
283#[cfg(test)]
284#[allow(clippy::unwrap_used, clippy::expect_used)]
285mod tests {
286    use super::*;
287    use crate::drive::docs::types::Document;
288
289    fn body(json: serde_json::Value) -> Body {
290        serde_json::from_value(json).unwrap()
291    }
292
293    fn paragraph(start: i64, end: i64, text: &str, style: &str) -> serde_json::Value {
294        serde_json::json!({
295            "startIndex": start, "endIndex": end,
296            "paragraph": {
297                "elements": [{"textRun": {"content": text}}],
298                "paragraphStyle": {"namedStyleType": style},
299            },
300        })
301    }
302
303    #[test]
304    fn flatten_of_an_empty_body_is_empty() {
305        assert!(flatten(&body(serde_json::json!({"content": []}))).is_empty());
306    }
307
308    #[test]
309    fn flatten_emits_elements_in_index_order() {
310        let elements = flatten(&body(serde_json::json!({"content": [
311            {"endIndex": 1, "sectionBreak": {}},
312            paragraph(1, 10, "Overview\n", "HEADING_1"),
313            paragraph(10, 20, "Body text\n", "NORMAL_TEXT"),
314        ]})));
315        assert_eq!(
316            elements.iter().map(|e| e.start_index).collect::<Vec<_>>(),
317            vec![0, 1, 10]
318        );
319        assert_eq!(elements[0].kind, ElementKind::SectionBreak);
320        assert_eq!(elements[1].kind, ElementKind::Paragraph);
321    }
322
323    /// `flatten_content` is what a header/footer/footnote segment uses —
324    /// same content shape as a `Body`, minus the wrapper. It must agree with
325    /// `flatten` exactly for the same content.
326    #[test]
327    fn flatten_content_agrees_with_flatten_on_the_same_content() {
328        let via_body = flatten(&body(serde_json::json!({
329            "content": [paragraph(0, 9, "Header\n", "NORMAL_TEXT")],
330        })));
331        let structural: Vec<StructuralElement> = serde_json::from_value(serde_json::Value::Array(
332            vec![paragraph(0, 9, "Header\n", "NORMAL_TEXT")],
333        ))
334        .unwrap();
335        let via_content = flatten_content(&structural);
336        assert_eq!(via_body, via_content);
337        assert_eq!(via_content[0].text, "Header");
338    }
339
340    /// The paragraph terminator is dropped from `text` and kept in
341    /// `end_index`. Adjusting both, or neither, is the bug this pins.
342    #[test]
343    fn flatten_strips_the_terminator_from_text_but_not_from_end_index() {
344        let elements = flatten(&body(serde_json::json!({
345            "content": [paragraph(1, 10, "Overview\n", "HEADING_1")],
346        })));
347        assert_eq!(elements[0].text, "Overview");
348        assert_eq!(elements[0].end_index, 10);
349    }
350
351    /// Trailing content the user actually typed survives; only the single
352    /// paragraph terminator goes.
353    #[test]
354    fn flatten_strips_exactly_one_newline_not_a_trim_end() {
355        let elements = flatten(&body(serde_json::json!({
356            "content": [paragraph(1, 10, "text  \u{b}\n", "NORMAL_TEXT")],
357        })));
358        assert_eq!(elements[0].text, "text  \u{b}");
359    }
360
361    #[test]
362    fn flatten_joins_multiple_text_runs_in_one_paragraph() {
363        let elements = flatten(&body(serde_json::json!({"content": [{
364            "startIndex": 1, "endIndex": 20,
365            "paragraph": {"elements": [
366                {"textRun": {"content": "Hello "}},
367                {"textRun": {"content": "bold"}},
368                {"textRun": {"content": " world\n"}},
369            ]},
370        }]})));
371        assert_eq!(elements[0].text, "Hello bold world");
372    }
373
374    /// An inline image consumes index space but contributes no text, so the
375    /// paragraph's text is shorter than its span. That is correct.
376    #[test]
377    fn flatten_counts_an_inline_object_as_index_space_with_no_text() {
378        let elements = flatten(&body(serde_json::json!({"content": [{
379            "startIndex": 1, "endIndex": 5,
380            "paragraph": {"elements": [
381                {"textRun": {"content": "a"}},
382                {"inlineObjectElement": {"inlineObjectId": "kix.1"}},
383                {"textRun": {"content": "b\n"}},
384            ]},
385        }]})));
386        assert_eq!(elements[0].text, "ab");
387        assert_eq!(elements[0].end_index - elements[0].start_index, 4);
388    }
389
390    /// Without the container row a table is invisible in the flat list
391    /// except as an index gap, and nothing addresses the table as a whole.
392    #[test]
393    fn flatten_emits_the_table_container_before_its_cell_paragraphs() {
394        let elements = flatten(&body(serde_json::json!({"content": [{
395            "startIndex": 1, "endIndex": 40,
396            "table": {
397                "rows": 1, "columns": 2,
398                "tableRows": [{"tableCells": [
399                    {"content": [paragraph(3, 10, "Name\n", "NORMAL_TEXT")]},
400                    {"content": [paragraph(10, 20, "Value\n", "NORMAL_TEXT")]},
401                ]}],
402            },
403        }]})));
404        assert_eq!(elements.len(), 3);
405        assert_eq!(elements[0].kind, ElementKind::Table);
406        assert_eq!(elements[0].rows, Some(1));
407        assert_eq!(elements[0].columns, Some(2));
408        assert_eq!(elements[1].text, "Name");
409        assert_eq!(elements[2].text, "Value");
410    }
411
412    #[test]
413    fn flatten_records_a_path_for_a_cell_paragraph() {
414        let elements = flatten(&body(serde_json::json!({"content": [{
415            "startIndex": 1, "endIndex": 40,
416            "table": {"rows": 1, "columns": 2, "tableRows": [{"tableCells": [
417                {"content": [paragraph(3, 10, "a\n", "NORMAL_TEXT")]},
418                {"content": [paragraph(10, 20, "b\n", "NORMAL_TEXT")]},
419            ]}]},
420        }]})));
421        assert_eq!(elements[0].path, vec![0]);
422        assert_eq!(elements[0].depth(), 0);
423        // [table, row 0, cell 1, element 0]
424        assert_eq!(elements[2].path, vec![0, 0, 1, 0]);
425        assert_eq!(elements[2].depth(), 3);
426    }
427
428    #[test]
429    fn flatten_recurses_into_a_nested_table() {
430        let elements = flatten(&body(serde_json::json!({"content": [{
431            "startIndex": 1, "endIndex": 60,
432            "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{"content": [{
433                "startIndex": 3, "endIndex": 50,
434                "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
435                    "content": [paragraph(5, 12, "deep\n", "NORMAL_TEXT")],
436                }]}]},
437            }]}]}]},
438        }]})));
439        let kinds: Vec<_> = elements.iter().map(|e| e.kind).collect();
440        assert_eq!(
441            kinds,
442            vec![
443                ElementKind::Table,
444                ElementKind::Table,
445                ElementKind::Paragraph
446            ]
447        );
448        assert_eq!(elements[2].text, "deep");
449        // outer table, row, cell, inner table, row, cell, paragraph — a
450        // 7-segment path, so 6 levels below the top.
451        assert_eq!(elements[2].path, vec![0, 0, 0, 0, 0, 0, 0]);
452        assert_eq!(elements[2].depth(), 6);
453    }
454
455    #[test]
456    fn flatten_records_a_list_id_for_a_bullet() {
457        let elements = flatten(&body(serde_json::json!({"content": [{
458            "startIndex": 1, "endIndex": 8,
459            "paragraph": {
460                "elements": [{"textRun": {"content": "item\n"}}],
461                "bullet": {"listId": "kix.l1", "nestingLevel": 0},
462            },
463        }]})));
464        assert_eq!(elements[0].list_id.as_deref(), Some("kix.l1"));
465    }
466
467    #[test]
468    fn flatten_walks_a_table_of_contents() {
469        let elements = flatten(&body(serde_json::json!({"content": [{
470            "startIndex": 1, "endIndex": 30,
471            "tableOfContents": {"content": [paragraph(2, 12, "Overview\n", "NORMAL_TEXT")]},
472        }]})));
473        assert_eq!(elements[0].kind, ElementKind::TableOfContents);
474        assert_eq!(elements[1].text, "Overview");
475    }
476
477    /// Indices come from the server and are UTF-16 code units, so an emoji
478    /// spans two of them while being one `char`. Nothing here recomputes
479    /// them; this pins that they are passed through untouched.
480    #[test]
481    fn indices_are_utf16_code_units_not_chars() {
482        let elements = flatten(&body(serde_json::json!({
483            // "😀" is 1 char, 2 UTF-16 code units, 4 UTF-8 bytes, so the
484            // server reports a span of 3 for the emoji plus its newline.
485            "content": [paragraph(1, 4, "\u{1F600}\n", "NORMAL_TEXT")],
486        })));
487        assert_eq!(elements[0].end_index - elements[0].start_index, 3);
488        assert_eq!(elements[0].text.chars().count(), 1);
489        assert_eq!(elements[0].text.encode_utf16().count(), 2);
490        assert_eq!(elements[0].text.len(), 4);
491    }
492
493    fn outline_of(json: serde_json::Value) -> TabOutline {
494        let doc: Document = serde_json::from_value(json).unwrap();
495        let tabs = doc.resolved_tabs();
496        outline(&tabs[0])
497    }
498
499    #[test]
500    fn outline_counts_each_element_kind() {
501        let counts = outline_of(serde_json::json!({"body": {"content": [
502            {"endIndex": 1, "sectionBreak": {}},
503            paragraph(1, 10, "A\n", "HEADING_1"),
504            paragraph(10, 20, "b\n", "NORMAL_TEXT"),
505            {"startIndex": 20, "endIndex": 40, "table": {
506                "rows": 1, "columns": 1,
507                "tableRows": [{"tableCells": [{"content": [
508                    paragraph(22, 30, "c\n", "NORMAL_TEXT")]}]}],
509            }},
510        ]}}));
511        assert_eq!(counts.section_breaks, 1);
512        assert_eq!(counts.tables, 1);
513        // Three paragraphs: two top-level plus the one inside the cell.
514        assert_eq!(counts.paragraphs, 3);
515    }
516
517    #[test]
518    fn outline_lists_headings_in_document_order_with_their_index_ranges() {
519        let counts = outline_of(serde_json::json!({"body": {"content": [
520            paragraph(1, 10, "Overview\n", "HEADING_1"),
521            paragraph(10, 20, "prose\n", "NORMAL_TEXT"),
522            paragraph(20, 30, "Goals\n", "HEADING_2"),
523        ]}}));
524        assert_eq!(counts.headings.len(), 2);
525        assert_eq!(counts.headings[0].text, "Overview");
526        assert_eq!(counts.headings[0].style, "HEADING_1");
527        assert_eq!(counts.headings[0].start_index, 1);
528        assert_eq!(counts.headings[1].text, "Goals");
529        assert_eq!(counts.headings[1].end_index, 30);
530    }
531
532    /// A well-formed document's top-level structure uses `TITLE`; omitting
533    /// it would make the outline start at the second section.
534    #[test]
535    fn outline_treats_title_and_subtitle_as_headings() {
536        let counts = outline_of(serde_json::json!({"body": {"content": [
537            paragraph(1, 10, "The Title\n", "TITLE"),
538            paragraph(10, 20, "A subtitle\n", "SUBTITLE"),
539            paragraph(20, 30, "prose\n", "NORMAL_TEXT"),
540        ]}}));
541        let styles: Vec<_> = counts.headings.iter().map(|h| h.style.as_str()).collect();
542        assert_eq!(styles, vec!["TITLE", "SUBTITLE"]);
543    }
544
545    #[test]
546    fn outline_characters_is_the_highest_end_index() {
547        let counts = outline_of(serde_json::json!({"body": {"content": [
548            paragraph(1, 10, "a\n", "NORMAL_TEXT"),
549            paragraph(10, 137, "b\n", "NORMAL_TEXT"),
550        ]}}));
551        assert_eq!(counts.characters, 137);
552    }
553
554    #[test]
555    fn outline_of_an_empty_tab_is_all_zeroes() {
556        let counts = outline_of(serde_json::json!({"body": {"content": []}}));
557        assert_eq!(counts.characters, 0);
558        assert_eq!(counts.paragraphs, 0);
559        assert!(counts.headings.is_empty());
560    }
561
562    #[test]
563    fn outline_carries_the_tabs_identity() {
564        let doc: Document = serde_json::from_value(serde_json::json!({"tabs": [{
565            "tabProperties": {"tabId": "t.0", "title": "Overview", "nestingLevel": 0},
566            "documentTab": {"body": {"content": [paragraph(1, 10, "a\n", "NORMAL_TEXT")]}},
567        }]}))
568        .unwrap();
569        let counts = outline(&doc.resolved_tabs()[0]);
570        assert_eq!(counts.tab_id.as_deref(), Some("t.0"));
571        assert_eq!(counts.title.as_deref(), Some("Overview"));
572        assert_eq!(counts.paragraphs, 1);
573    }
574
575    #[test]
576    fn element_kind_labels_match_the_serde_wire_form() {
577        for kind in [
578            ElementKind::Paragraph,
579            ElementKind::Table,
580            ElementKind::SectionBreak,
581            ElementKind::TableOfContents,
582        ] {
583            let wire = serde_json::to_value(kind).unwrap();
584            assert_eq!(wire.as_str().unwrap(), kind.as_str());
585        }
586    }
587}