Skip to content

Stop a delete claiming whitespace an insert never gave it - #32

Merged
MarcelInTO merged 1 commit into
mainfrom
cdata-indent
Aug 23, 2026
Merged

MarcelInTO merged 1 commit into
mainfrom
cdata-indent

Conversation

@MarcelInTO

Copy link
Copy Markdown
Owner

Closes #1.

In a container holding any text or CDATA child, inserting a node and then deleting it destroyed
a sibling's indentation — the document came back one node short, silently, in a region the user
never edited. Since byte-preserving saves are the point of the whitespace bookkeeping, that
quietly reformatted somebody's document.

Cause

Insert and delete disagreed about who owned the whitespace in front of a node.
XmlLayout.HasTextContent returned true for any text-ish child, so a container that is
block-formatted and happens to hold one CDATA among its indented elements was classified inline:
InsertNode.PlanLayout planned no indentation and added one node, while DeleteNode claimed
whatever whitespace preceded the node and removed two.

The fix — both halves, because each covers a case the other does not

HasTextContent now asks whether the text shares a line, rather than whether it exists. Text
that starts a line is preceded by whitespace carrying a line break; the "Call me " of
<p>Call me <i>Ishmael</i></p> is not, because with PreserveWhitespace on there is no separate
whitespace node in front of it to find. So a CDATA section on a line of its own leaves its
container block-formatted, and an insert there is indented like its siblings.

DeleteNode claims the leading whitespace only where ShouldIndent says an insert would have
created it
— the same question PlanLayout asks, so the two cannot drift. In a container that
really is inline this leaves a pre-existing node's indent behind rather than destroying it, which
is the deliberate direction to err: a delete should not invent layout changes.

Why both, and how they are pinned

Mutation testing is why both fixes are here rather than one. Against the issue's own
reproducing document, reverting either fix alone still passed — each is independently sufficient
there, so shipping both with only that document as evidence would have left one asserted by
nothing.

A new drill section builds both container shapes rather than relying on a fixture, so it runs on
every document CI drills. The oracle is OuterXml, not a node count — it catches whitespace that
changed value as well as whitespace that went missing.

mutation goes red on
HasTextContent back to "any text child" a container holding CDATA on a line of its own is still laid out as a block, the insert brings its own indentation (7 -> 8, want +2)
delete claims whitespace unconditionally insert then delete leaves the inline container byte for byte (4 of 5 children) — the net −1, by name
both, i.e. the code as it was all four, including the block container's round-trip

All five CI fixtures pass (405 / 409 / 396 / 383 / 388), as do both minimal documents from the
issue (381 each) — withcdata.xml failed before this change and passes now.

Also done, as the issue asked

The two sections that shared the message the document is back to its original children now say
which one failed and print the counts. "was 19, now 18" is what made this diagnosable in the
first place, and the drill's output is the only diagnostic a PTY run leaves behind.

One correction to the issue

It cites src/Application/Samples/basket.xml as failing the same way. That file no longer
exists
— it went with the WinForms tree in 1ad7cf6 — so that half of the evidence cannot be
reproduced from the repo. The minimal documents in the issue still reproduce it exactly.

XmlFormatWriter has its own HasTextContent and is deliberately untouched: it governs
pretty-print reflow, not the whitespace-preserving edit path, and changing it would move saved
bytes.

🤖 Generated with Claude Code

https://claude.ai/code/session_016X4pgrsMBcsXjqWDo2gBBt

In a container holding any text or CDATA child, inserting a node and then
deleting it destroyed a sibling's indentation: the document came back one node
short, silently, in a region the user never edited. Since byte-preserving
saves are the point of the whitespace bookkeeping, that quietly reformatted
somebody's document.

Insert and delete disagreed about who owned the whitespace in front of a node.
XmlLayout.HasTextContent returned true for ANY text-ish child, so a container
that is block-formatted and happens to hold one CDATA among its indented
elements was classified inline: InsertNode.PlanLayout planned no indentation
and added one node, while DeleteNode claimed whatever whitespace preceded the
node and removed two. Net minus one per cycle.

Both halves of the disagreement are fixed, because each covers a case the
other does not.

HasTextContent now asks whether the text shares a line with anything else,
rather than whether it exists. Text that starts a line is preceded by
whitespace carrying a line break; the "Call me " of <p>Call me <i>Ishmael</i>
</p> is not, because with PreserveWhitespace on there is no separate
whitespace node in front of it to find. So a CDATA section on a line of its
own leaves its container block-formatted, and an insert there is indented like
its siblings.

DeleteNode claims the leading whitespace only where ShouldIndent says an
insert would have created it — the same question PlanLayout asks, so the two
cannot drift. In a container that really is inline this leaves a pre-existing
node's indent behind rather than destroying it, which is the deliberate
direction to err: a delete should not invent layout changes.

Pinned by a new drill section that builds both container shapes rather than
relying on a fixture, so it runs on every document CI drills. The oracle is
OuterXml, not a node count: it catches whitespace that changed value as well as
whitespace that went missing.

Mutation-testing is why both fixes are here rather than one. Against the
issue's own reproducing document, reverting EITHER fix alone still passed —
each is independently sufficient there, so shipping both with only that
document as evidence would have left one of them asserted by nothing. The new
section separates them: reverting the classification turns the block
container's checks red, reverting the delete gate turns the inline container's
round-trip red ("4 of 5 children" — the net minus one, by name), and reverting
both reproduces the original failure.

The drill's own diagnosis is improved as the issue asked: the two sections that
shared the message "the document is back to its original children" now say
which one failed and print the counts, which is what made this diagnosable at
all.

One correction to the issue: it cites src/Application/Samples/basket.xml as
failing the same way. That file no longer exists — it went with the WinForms
tree in 1ad7cf6 — so that half of the evidence cannot be reproduced from the
repo. The minimal documents in the issue still reproduce it exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4pgrsMBcsXjqWDo2gBBt
Comment thread src/Fux/Drill.cs
// The oracle is OuterXml, not a node count: it catches whitespace that changed value
// as well as whitespace that went missing.
{
var layoutPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fux-drill-layout.xml");
@MarcelInTO
MarcelInTO merged commit c0986ce into main Aug 23, 2026
5 checks passed
@MarcelInTO
MarcelInTO deleted the cdata-indent branch August 23, 2026 05:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Insert-then-delete destroys a sibling's indentation when the container holds text or CDATA

2 participants