Skip to content

Commit 1c79b84

Browse files
authored
Merge pull request quarto-dev#336 from quarto-dev/bugfix/bd-3zp3z4jx-link-url-corrupted-write
fix(reconcile): don't inherit an adjacent link's URL on write-back (bd-3zp3z4jx)
2 parents 19aff01 + be7a6d2 commit 1c79b84

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# bd-3zp3z4jx — link URL corrupted on write-back
2+
3+
**Date:** 2026-06-23
4+
**Branch:** `braid/bd-3zp3z4jx-link-url-corrupted-write` (off `origin/main`)
5+
6+
## Symptom
7+
8+
Editing a paragraph so it contains a link whose URL differs from the original
9+
(adding a new link, or changing an existing link's URL) persisted the **wrong**
10+
URL: a new link inherited an *adjacent* link's URL, and a changed URL silently
11+
reverted to the old one.
12+
13+
Discovered while testing the rich-text editor (bd-sjb4pzx8), but the bug is in
14+
the **shared write-back path** (`apply_node_edit` → reconcile → incremental
15+
writer), so it affects the monospaced textarea editor's link edits too.
16+
17+
## Root cause
18+
19+
The incremental writer's **inline splice** (`assemble_recursed_container` in
20+
`crates/pampa/src/writers/incremental.rs`) preserves a container inline's
21+
*delimiters* verbatim from the original source while re-writing only its
22+
children. For a `Link`, the closing delimiter is `](url "title")` — i.e. the URL
23+
is part of the verbatim-copied delimiter, **not** a recursable child.
24+
25+
The reconciler (`compute_inline_alignments` in
26+
`crates/quarto-ast-reconcile/src/compute.rs`) decided two container inlines
27+
"correspond" (→ `RecurseIntoContainer`) using **only the type discriminant**.
28+
So any new `Link` matched any old `Link` regardless of target, and the splice
29+
then copied the old link's `](url)` over the new one.
30+
31+
## Fix
32+
33+
In the reconciler's type-match step, require a container's **non-child identity**
34+
to be unchanged before treating two containers as the same (and recursing):
35+
36+
- `Link` / `Image`: `target` (url, title) **and** `attr` equal
37+
- `Span`: `attr` equal
38+
- (`Custom`: `type_name` equal — pre-existing)
39+
40+
When that identity differs, the new inline falls through to `UseAfter`, which
41+
re-serializes it via the qmd writer (which emits the URL straight from the AST —
42+
`link.target.0`), producing the correct output. Single-line, value-only
43+
comparison; no type or API changes.
44+
45+
## Tests (TDD — failing first)
46+
47+
`crates/pampa/tests/integration/node_edit_tests.rs`:
48+
49+
- `apply_node_edit_preserves_distinct_link_urls` — adding a 2nd link keeps both
50+
distinct URLs.
51+
- `apply_node_edit_changes_existing_link_url` — changing a link's URL persists.
52+
53+
Both failed before the fix (new link got the old URL), pass after.
54+
55+
## Verification
56+
57+
- `quarto-ast-reconcile` + `pampa`: 4248 pass.
58+
- Full workspace `cargo nextest run --workspace`: **10337 pass**, 0 regressions.
59+
- `cargo xtask verify --skip-hub-build`: Rust build + clippy (`-D warnings`) +
60+
fmt clean. (ts-packages/hub legs need `npm install`; not affected by this
61+
Rust-only change — CI covers them.)

crates/pampa/tests/integration/node_edit_tests.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,3 +1424,46 @@ fn preserve_leaf_variant_does_not_fire_on_multi_block_replacement() {
14241424
bl.content[0][0]
14251425
);
14261426
}
1427+
1428+
// =============================================================================
1429+
// bd-3zp3z4jx — link URL corruption on write-back
1430+
// =============================================================================
1431+
1432+
/// Editing a paragraph so it gains a SECOND link must keep both links' distinct
1433+
/// URLs. Repro for bd-3zp3z4jx: a new link in a multi-link paragraph was getting
1434+
/// an adjacent link's URL on write-back (found via the rich-text editor, but the
1435+
/// bug is in apply_node_edit / the incremental writer, shared by both editors).
1436+
#[test]
1437+
fn apply_node_edit_preserves_distinct_link_urls() {
1438+
// Original paragraph has ONE link.
1439+
let content = "Text with a [link](https://existing.example) here.\n";
1440+
// Edit it to add a NEW link (before the existing one) with a DIFFERENT url.
1441+
let replacement = "Text with a [newlink](https://added.example) and a [link](https://existing.example) here.\n";
1442+
let result = edit_block(content, 0, replacement);
1443+
1444+
assert!(
1445+
result.contains("https://added.example"),
1446+
"the NEW link's URL must survive write-back; got: {result:?}"
1447+
);
1448+
assert!(
1449+
result.contains("https://existing.example"),
1450+
"the original link's URL must survive write-back; got: {result:?}"
1451+
);
1452+
}
1453+
1454+
/// Changing an EXISTING link's URL must persist the new URL (bd-3zp3z4jx).
1455+
/// Previously the inline splice preserved the old `](url)` delimiter verbatim.
1456+
#[test]
1457+
fn apply_node_edit_changes_existing_link_url() {
1458+
let content = "Go to [the site](https://old.example) now.\n";
1459+
let replacement = "Go to [the site](https://new.example) now.\n";
1460+
let result = edit_block(content, 0, replacement);
1461+
assert!(
1462+
result.contains("https://new.example"),
1463+
"changed URL must persist; got: {result:?}"
1464+
);
1465+
assert!(
1466+
!result.contains("https://old.example"),
1467+
"old URL must be gone; got: {result:?}"
1468+
);
1469+
}

crates/quarto-ast-reconcile/src/compute.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,9 +700,26 @@ fn compute_inline_alignments<'a>(
700700
if !is_container_inline(orig_inline) {
701701
return false;
702702
}
703-
// For Custom inlines, also check type_name matches
703+
// A container's non-child "identity" is preserved VERBATIM from the
704+
// original source by the inline splice (assemble_recursed_container
705+
// copies the delimiters around the children) — it is NOT a
706+
// recursable child. So two containers may only be treated as "the
707+
// same" (and recursed) when that identity is unchanged; otherwise
708+
// we must fall through to UseAfter, which re-serializes the new
709+
// inline with its correct target/attr.
710+
//
711+
// bd-3zp3z4jx: without this, a NEW link in a multi-link paragraph
712+
// matched an OLD link by type alone and inherited the old link's
713+
// URL (the `](url)` is part of the verbatim-copied closing
714+
// delimiter). The non-child identity is: Link/Image `target`+`attr`,
715+
// Span `attr`, Custom `type_name`.
704716
match (*orig_inline, exec_inline) {
705717
(Inline::Custom(o), Inline::Custom(e)) => o.type_name == e.type_name,
718+
(Inline::Link(o), Inline::Link(e)) => o.target == e.target && o.attr == e.attr,
719+
(Inline::Image(o), Inline::Image(e)) => {
720+
o.target == e.target && o.attr == e.attr
721+
}
722+
(Inline::Span(o), Inline::Span(e)) => o.attr == e.attr,
706723
_ => true,
707724
}
708725
});

0 commit comments

Comments
 (0)