Skip to content

docs find-replace --first / format --match corrupt text in paragraphs containing non-text inline elements (chips, images): client-side offset math ignores skipped element index width #818

Description

@sebsnyk

Summary

gog docs find-replace --first (and the markdown / all-occurrences path that goes through the same matcher) computes wrong document indices when the target paragraph contains a non-text inline element before/within the match — a person smart chip, inline image, footnote reference, equation, etc. The delete+insert lands one (or more) positions off, corrupting the document: it drops or doubles a couple of characters near, but not at, the match site. The error compounds with each successive call.

gog docs format --match shares the same buggy offset path and is affected in the same way (it styles the wrong range).

Plain paragraphs — including those with multi-byte characters like em-dashes or smart quotes, or with multiple style runs (bold/inline-code) — are not affected, because those runs stay index-contiguous. The trigger is specifically a non-TextRun inline element occupying index space inside the paragraph.

Version

gog version
v0.25.0-9-g10bb60d8 (10bb60d871f4 2026-06-12T09:44:12Z)

Reproducer (standalone, copy-paste)

# 1. Create a throwaway doc
DOC=$(gog docs create "fr-repro" -j | python3 -c "import json,sys;print(json.load(sys.stdin)['file']['id'])")

# 2. One paragraph with an em-dash and an inline code span
printf 'A paragraph with rule-based comments — use inline code and bold here when so.\n' > /tmp/repro.md
gog docs write "$DOC" --replace --markdown --tab=t.0 --file /tmp/repro.md

# 3. Turn "rule-based comments" into a person smart chip.
#    This puts a non-TextRun inline element in the paragraph, before "inline code".
gog docs insert-person "$DOC" [email protected] --at "rule-based comments" --tab=t.0

gog docs read "$DOC"
# A paragraph with  — use inline code and bold here when so.

# 4. find-replace a substring that comes AFTER the chip
gog docs find-replace "$DOC" "inline code" "XXXX" --first --tab=t.0

gog docs read "$DOC"

Expected: A paragraph with [chip] — use XXXX and bold here when so.

Actual (corrupted, off by one):

A paragraph with  — useXXXXe and bold here when so.

The replacement was applied one index too early: it ate the space + inline cod, left a stray trailing e, and inserted XXXX before the space. Repeated find-replace calls on the same paragraph keep shifting and mangle further — matching the originally observed "rule-based comments" → "rule-basecomments", "if so," → "if soso," drift.

gog docs format --match "inline code" --underline --tab=t.0 on the same paragraph underlines 109..120 instead of the true 110..121 — same one-off, so it styles the trailing space and drops the final character.

Root cause

findTextInParagraph in internal/cmd/docs_mutation.go builds the paragraph's comparable text by concatenating only TextRun content, skipping every non-TextRun element, and then anchors all offsets on the first run's StartIndex plus the accumulated concatenated-text length:

// internal/cmd/docs_mutation.go
for _, pe := range para.Elements {
    if pe.TextRun == nil {
        continue            // <-- non-text element skipped, but its index width is lost
    }
    if first {
        paraStart = pe.StartIndex   // <-- only the FIRST run's start is used as the anchor
        first = false
    }
    paraText.WriteString(pe.TextRun.Content)
}
...
matchStart := paraStart + utf16Len(text[:absIdx])   // assumes runs are index-contiguous from paraStart
matchEnd   := matchStart + utf16Len(searchText)

This is correct only when all runs are contiguous and there are no non-TextRun elements before the match. A person chip / inline object / footnote reference occupies index space in the document but contributes nothing to paraText, so every match after it is shifted by that element's index width. utf16Len itself is correct — the bug is the offset model, not UTF-16 conversion (which is why em-dashes and smart quotes alone don't trigger it).

The sibling --at / find-range resolver already does this correctly — it tracks each run's own StartIndex:

// internal/cmd/docs_find_range.go (appendDocsComparableParagraphText)
for _, pe := range para.Elements {
    if pe == nil || pe.TextRun == nil {
        (*segment)++
        continue
    }
    runOffset := int64(0)
    for _, r := range pe.TextRun.Content {
        startIndex := pe.StartIndex + runOffset   // anchors on THIS run's start
        endIndex := startIndex + utf16RuneLen(r)
        runOffset = endIndex - pe.StartIndex
        ...
    }
}

Fix: make findTextInParagraph / findTextMatches anchor each match on the containing run's actual StartIndex (as find-range does), instead of firstRunStart + concatenatedTextLen. Ideally consolidate both matchers onto the find-range text-unit walker so there is a single, correct implementation.

Two further nits in the same function:

  • matchEnd := matchStart + utf16Len(searchText) uses the original-case searchText; with --match-case=false the matched substring can differ in length from the search term for some Unicode case foldings. Use the actual matched slice length.
  • The matcher can't match across a run boundary that a non-text element splits, which the find-range walker handles via its segment model.

Affected vs. safe (confirmed empirically against a chip-bearing paragraph)

Command Status
gog docs find-replace --first Broken — corrupts (off by the skipped element's index width)
gog docs find-replace (markdown / all-occurrences path) Broken — same matcher (findTextMatches)
gog docs format --match / --match-all Broken — styles the wrong range (same matcher)
gog docs edit <find> <replace> Safe — server-side ReplaceAllText, no client offset math
gog docs find-range Safe — uses the per-run StartIndex walker
gog docs insert --at Safe — uses the --at anchor resolver
gog docs delete --at Safe — uses the --at anchor resolver

gog docs find-replace (plain, all-occurrences, no --first) routes through the Docs API ReplaceAllText and is also safe; only the paths that compute client-side indices via findTextMatches/findTextInParagraph are affected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority bug or improvement with limited blast radius.impact:data-lossThis issue is about lost, corrupted, or silently dropped user/session/config data.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions