Skip to main content

ronin_core/
transform.rs

1//! Pure CST→CST structural-edit transforms (E008 Phase 1a, ADR-0007).
2//!
3//! This module gives `ronin-core` the **named** structural-edit vocabulary the
4//! E008 tree/form and table surfaces build on — insert / remove / reorder a
5//! struct field, map entry, or list/tuple element; set a value; rename a
6//! field/key; swap an enum variant; and add a field across every record of a
7//! list. Each op is a single, non-destructive [`apply_structural`] call that
8//! returns a fresh [`CstDocument`] sharing every untouched green subtree with
9//! the original (structural sharing), so every region the edit did not touch
10//! prints **byte-for-byte** identically (FR-013) and adjacent trivia on
11//! surviving siblings is preserved (FR-021).
12//!
13//! # Placement (ADR-0007)
14//!
15//! These are the *pure CST→CST transform functions* of the split decided by
16//! ADR-0007: they live in `ronin-core` (reusable by a future LSP/web surface),
17//! navigate the document only through the typed [`crate::syntax::ast`] accessors,
18//! and compose over the existing non-destructive [`apply_edit`] primitive — they
19//! introduce **no** parallel edit engine. The *selection→target resolution* and
20//! the *view/undo orchestration* stay in `ronin-app`; selection, focus,
21//! view-state, and undo wiring MUST NOT enter this module.
22//!
23//! # WASM-clean (ADR-0007 / HINT-001 / project-instructions §II)
24//!
25//! This module adds **no** filesystem / UI / async-runtime / native / time
26//! dependency. It uses only `std` and `ronin-core`'s own CST types + `apply_edit`,
27//! so the `wasm32-unknown-unknown` build of `ronin-core` stays green.
28//!
29//! # Addressing scheme
30//!
31//! Each [`StructuralOp`] carries its target as an **`ast`-navigable address**: a
32//! parent collection node ([`ParentRef`]) plus, where a specific element is
33//! addressed, a **child index** into that parent's elements (fields / entries /
34//! items, in source order). The caller (ronin-app, later) resolves a tree/table
35//! selection to one of these addresses; this module then re-resolves the address
36//! to a located CST node and composes the appropriate [`apply_edit`] call(s).
37//! Addressing by *index into the located parent* (rather than by a raw
38//! `SyntaxNode` handle) keeps a multi-step op (reorder, variant swap,
39//! add-field-across-rows) re-resolvable against each intermediate green tree the
40//! composition produces.
41//!
42//! # Outcome
43//!
44//! Every op returns a [`TransformOutcome`]: [`TransformOutcome::Applied`] with the
45//! new document, or [`TransformOutcome::Blocked`] with a [`BlockedReason`]. A
46//! `Blocked` outcome leaves the input CST **unchanged** and produces no edit (a
47//! no-op never corrupts the document — project-instructions §I).
48
49use crate::edit::{apply_edit, EditOperation, EditTarget, TriviaPolicy};
50use crate::parser::CstDocument;
51use crate::syntax::ast;
52use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
53
54/// Which collection a structural op targets, addressed by an `ast`-navigable
55/// reference (parent kind + index into the document).
56///
57/// A [`ParentRef`] names *one* collection node in the document being
58/// transformed. The caller resolves a selection to a parent + index; this module
59/// re-resolves the parent (by walking the document's value tree to the node whose
60/// byte range matches) and then addresses elements by index within it.
61///
62/// `#[non_exhaustive]` so future container kinds can be added without a breaking
63/// change.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum ParentRef {
67    /// A struct / anonymous struct node (its `field: value` entries).
68    Struct(SyntaxNode),
69    /// A map node (its `key: value` entries).
70    Map(SyntaxNode),
71    /// A list node (its elements).
72    List(SyntaxNode),
73    /// A tuple node (its positional elements).
74    Tuple(SyntaxNode),
75    /// An enum variant's struct-like payload (its `field: value` entries).
76    EnumVariant(SyntaxNode),
77}
78
79impl ParentRef {
80    /// The underlying parent [`SyntaxNode`].
81    #[must_use]
82    pub fn node(&self) -> &SyntaxNode {
83        match self {
84            Self::Struct(n)
85            | Self::Map(n)
86            | Self::List(n)
87            | Self::Tuple(n)
88            | Self::EnumVariant(n) => n,
89        }
90    }
91}
92
93/// A single named structural-edit operation (the E008 transform vocabulary).
94///
95/// The set is derived from FR-003 (add / remove / reorder / rename fields and
96/// elements; change an enum variant) and FR-007 (add / remove rows = elements
97/// with sibling-inferred style). Each op identifies its target by an
98/// `ast`-navigable address ([`ParentRef`] + child index); see the module docs.
99///
100/// `#[non_exhaustive]` so the vocabulary can grow without a breaking change.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum StructuralOp {
104    /// Insert a `field: value` entry into a struct (or `key: value` into a map)
105    /// at `index` (clamped to the entry count; appends past the end). For a map,
106    /// `name` is the key's literal RON text (e.g. `"k"` or `1`); for a struct it
107    /// is the bare field identifier.
108    InsertField {
109        /// The struct / map / enum-variant payload to insert into.
110        parent: ParentRef,
111        /// 0-based insertion index among existing entries (clamped; append at end).
112        index: usize,
113        /// The field name (struct: bare ident) or key literal (map: RON text).
114        name: String,
115        /// The new value's literal RON text.
116        value: String,
117    },
118
119    /// Remove the entry at `index` from a struct / map / enum-variant payload,
120    /// taking only the entry and its own trivia and normalizing the separator
121    /// (no dangling / doubled / orphaned comma) per FR-021.
122    RemoveField {
123        /// The struct / map / enum-variant payload to remove from.
124        parent: ParentRef,
125        /// 0-based index of the entry to remove.
126        index: usize,
127    },
128
129    /// Rename the struct-field name / map-key at `index` to `new_name` in place.
130    /// Blocks with [`BlockedReason::RenameCollision`] if `new_name` already names
131    /// another entry in the **same** parent (collision scope = the immediate
132    /// enclosing struct/map), leaving the document byte-unchanged (FR-003).
133    RenameKey {
134        /// The struct / map / enum-variant payload whose entry is renamed.
135        parent: ParentRef,
136        /// 0-based index of the entry to rename.
137        index: usize,
138        /// The replacement field name / key literal.
139        new_name: String,
140    },
141
142    /// Move the child at `from` to be at position `to` within `parent`, composed
143    /// as one transform (remove + re-insert) producing one new CST. Works for a
144    /// struct/map entry or a list/tuple element.
145    ReorderChild {
146        /// The collection whose child is moved.
147        parent: ParentRef,
148        /// 0-based source index of the child to move.
149        from: usize,
150        /// 0-based destination index (in the pre-move indexing).
151        to: usize,
152    },
153
154    /// Replace the value at `index` within `parent` with `value` (new literal RON
155    /// text). For a struct/map entry this replaces the entry's *value* (the part
156    /// after `:`); for a list/tuple it replaces the element.
157    SetValue {
158        /// The collection whose child value is replaced.
159        parent: ParentRef,
160        /// 0-based index of the entry/element whose value is set.
161        index: usize,
162        /// The replacement value's literal RON text.
163        value: String,
164    },
165
166    /// Insert an element into a list / tuple at `index` (clamped; append past the
167    /// end), adopting the collection's existing layout style — indentation and
168    /// trailing-comma convention inferred from its siblings, or the document's
169    /// default for an empty collection (AD-005, FR-007).
170    InsertElement {
171        /// The list / tuple to insert into.
172        parent: ParentRef,
173        /// 0-based insertion index among existing elements (clamped; append).
174        index: usize,
175        /// The new element's literal RON text.
176        value: String,
177    },
178
179    /// Remove the element at `index` from a list / tuple, taking only the element
180    /// and its own trivia and normalizing the separator (FR-021).
181    RemoveElement {
182        /// The list / tuple to remove from.
183        parent: ParentRef,
184        /// 0-based index of the element to remove.
185        index: usize,
186    },
187
188    /// Swap an enum variant's name + field set in place: rename the variant to
189    /// `new_name`, keep a field present in **both** old and new (its value/bytes
190    /// preserved), remove a field present **only** in the old variant, and add a
191    /// field present **only** in the new variant with `placeholder` as its value
192    /// (FR-003).
193    SwapEnumVariant {
194        /// The enum-variant node to swap.
195        variant: SyntaxNode,
196        /// The new variant name.
197        new_name: String,
198        /// The new variant's field set, in order (struct-like variants); empty
199        /// for a bare variant.
200        new_fields: Vec<String>,
201        /// The placeholder value text used for a field present only in the new
202        /// variant.
203        placeholder: String,
204    },
205
206    /// Add the field `name: value` to **every** record (struct element) of a
207    /// list, batched into one transform (the multi-node op of ADR-0007 /
208    /// data-model). Records that are not structs are skipped. The field is
209    /// appended to each record.
210    AddFieldAcrossRows {
211        /// The list whose struct elements each gain the field.
212        list: SyntaxNode,
213        /// The new field name (bare ident).
214        name: String,
215        /// The new field's value text.
216        value: String,
217    },
218}
219
220/// The result of applying a [`StructuralOp`].
221///
222/// `#[non_exhaustive]` so additional outcome arms can be added without a breaking
223/// change.
224#[derive(Debug, Clone)]
225#[non_exhaustive]
226pub enum TransformOutcome {
227    /// The op applied; carries the new document (untouched regions byte-identical).
228    Applied(CstDocument),
229    /// The op was rejected; the input document is unchanged and no edit was made.
230    Blocked(BlockedReason),
231}
232
233/// Why a [`StructuralOp`] was [`TransformOutcome::Blocked`].
234///
235/// A `Blocked` outcome guarantees the input CST is unchanged (FR-003, §I).
236///
237/// `#[non_exhaustive]` so future block reasons can be added without a breaking
238/// change.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
240#[non_exhaustive]
241pub enum BlockedReason {
242    /// A rename would collide with an existing field/key in the same struct/map.
243    RenameCollision,
244    /// The addressed parent / element could not be located in the document
245    /// (e.g. an out-of-range index, or a node from a different tree).
246    TargetNotFound,
247    /// The op is not valid for the addressed node (e.g. an op that needs a struct
248    /// applied to a list element, or new payload text that does not parse).
249    InvalidPayload,
250}
251
252// =============================================================================
253// Public entry point
254// =============================================================================
255
256/// Apply one named structural [`StructuralOp`] to `doc`, returning a
257/// [`TransformOutcome`].
258///
259/// The original `doc` is never mutated. On [`TransformOutcome::Applied`] the
260/// returned document shares every untouched green subtree with `doc`, so all
261/// untouched regions print byte-for-byte identically (FR-013) and surviving
262/// siblings keep their comments / blank lines / trailing commas (FR-021). On
263/// [`TransformOutcome::Blocked`] no edit is made and `doc` is unchanged.
264#[must_use]
265pub fn apply_structural(doc: &CstDocument, op: StructuralOp) -> TransformOutcome {
266    let result = match op {
267        StructuralOp::InsertField {
268            parent,
269            index,
270            name,
271            value,
272        } => insert_entry(doc, &parent, index, &name, &value),
273        StructuralOp::RemoveField { parent, index } => remove_child(doc, &parent, index),
274        StructuralOp::RenameKey {
275            parent,
276            index,
277            new_name,
278        } => rename_key(doc, &parent, index, &new_name),
279        StructuralOp::ReorderChild { parent, from, to } => reorder_child(doc, &parent, from, to),
280        StructuralOp::SetValue {
281            parent,
282            index,
283            value,
284        } => set_value(doc, &parent, index, &value),
285        StructuralOp::InsertElement {
286            parent,
287            index,
288            value,
289        } => insert_element(doc, &parent, index, &value),
290        StructuralOp::RemoveElement { parent, index } => remove_child(doc, &parent, index),
291        StructuralOp::SwapEnumVariant {
292            variant,
293            new_name,
294            new_fields,
295            placeholder,
296        } => swap_enum_variant(doc, &variant, &new_name, &new_fields, &placeholder),
297        StructuralOp::AddFieldAcrossRows { list, name, value } => {
298            add_field_across_rows(doc, &list, &name, &value)
299        }
300    };
301    match result {
302        Ok(new_doc) => TransformOutcome::Applied(new_doc),
303        Err(reason) => TransformOutcome::Blocked(reason),
304    }
305}
306
307// =============================================================================
308// Located addressing — re-resolve a ParentRef against `doc` by byte range.
309//
310// A ParentRef holds a node from (typically) `doc`'s own tree; we re-resolve it
311// against the live document so that a multi-step op stays valid against each
312// intermediate green tree the composition produces. Matching is by kind + byte
313// range, which is unique for a given tree.
314// =============================================================================
315
316/// Re-resolve a [`ParentRef`] to the live node of the same kind + **start offset**
317/// in `doc`.
318fn locate_parent(doc: &CstDocument, parent: &ParentRef) -> Option<SyntaxNode> {
319    let want = parent.node();
320    find_node(&doc.root(), want.kind(), want.text_range().start())
321}
322
323/// Re-resolve an arbitrary node to the live node of the same kind + start offset.
324fn locate_node(doc: &CstDocument, node: &SyntaxNode) -> Option<SyntaxNode> {
325    find_node(&doc.root(), node.kind(), node.text_range().start())
326}
327
328/// Depth-first search for a node of `kind` whose **start offset** equals `start`.
329///
330/// A container's start offset (its opening delimiter / name token) is **stable**
331/// across edits made *inside* it, so start-offset + kind re-resolves the same
332/// container even after a composed edit shrinks/grows its body — unlike an exact
333/// byte range, which changes when the body changes. A container's first child
334/// node always starts strictly after the container's first token, so (kind,
335/// start) is unambiguous for a given container kind.
336fn find_node(root: &SyntaxNode, kind: SyntaxKind, start: usize) -> Option<SyntaxNode> {
337    fn walk(node: &SyntaxNode, kind: SyntaxKind, start: usize, out: &mut Option<SyntaxNode>) {
338        if out.is_some() {
339            return;
340        }
341        if node.kind() == kind && node.text_range().start() == start {
342            *out = Some(node.clone());
343            return;
344        }
345        for child in node.children() {
346            let cr = child.text_range();
347            // Descend only where the wanted start falls within the child's span.
348            if cr.start() <= start && start < cr.end() {
349                walk(&child, kind, start, out);
350            }
351        }
352    }
353    let mut out = None;
354    walk(root, kind, start, &mut out);
355    out
356}
357
358/// The ordered child *entry/element* nodes of a located parent (skipping trivia,
359/// punctuation, and — for a struct/map/variant — anything that is not an entry).
360fn child_nodes(parent: &ParentRef, located: &SyntaxNode) -> Vec<SyntaxNode> {
361    match parent {
362        ParentRef::Struct(_) => ast::Struct::cast(located.clone())
363            .map(|s| s.fields().map(|f| f.syntax().clone()).collect())
364            .unwrap_or_default(),
365        ParentRef::Map(_) => ast::Map::cast(located.clone())
366            .map(|m| m.entries().map(|e| e.syntax().clone()).collect())
367            .unwrap_or_default(),
368        ParentRef::EnumVariant(_) => ast::EnumVariant::cast(located.clone())
369            .map(|v| v.entries().map(|e| e.syntax().clone()).collect())
370            .unwrap_or_default(),
371        ParentRef::List(_) => ast::List::cast(located.clone())
372            .map(|l| l.items().map(|v| v.syntax().clone()).collect())
373            .unwrap_or_default(),
374        ParentRef::Tuple(_) => ast::Tuple::cast(located.clone())
375            .map(|t| t.items().map(|v| v.syntax().clone()).collect())
376            .unwrap_or_default(),
377    }
378}
379
380/// The closing-delimiter token of a collection (`)`, `]`, or `}`), if any.
381fn closing_delimiter(node: &SyntaxNode) -> Option<SyntaxToken> {
382    node.children_with_tokens()
383        .filter_map(|el| el.as_token().cloned())
384        .filter(|t| {
385            matches!(
386                t.kind(),
387                SyntaxKind::RParen | SyntaxKind::RBracket | SyntaxKind::RBrace
388            )
389        })
390        .last()
391}
392
393// =============================================================================
394// Style inference (AD-005 / FR-007)
395// =============================================================================
396
397/// The layout style of a collection, inferred from its siblings (or the document
398/// default for an empty collection).
399#[derive(Debug, Clone)]
400struct CollectionStyle {
401    /// `true` if the collection lays out one element per line.
402    multiline: bool,
403    /// The indentation string for an element (leading whitespace of a sibling).
404    element_indent: String,
405    /// Indentation of the closing delimiter (one level out from elements).
406    closing_indent: String,
407    /// `true` if the predominant convention is a trailing comma after each element.
408    trailing_comma: bool,
409}
410
411/// Infer a collection's append style from its existing element siblings, falling
412/// back to the document's prevailing/default style for an empty collection
413/// (AD-005, FR-007).
414fn infer_style(
415    doc: &CstDocument,
416    located: &SyntaxNode,
417    elements: &[SyntaxNode],
418) -> CollectionStyle {
419    if elements.is_empty() {
420        return document_default_style(doc, located);
421    }
422
423    // Leading-whitespace indent of the predominant element (most-frequent;
424    // ties broken toward the last sibling — we scan and keep the last seen of
425    // the max-count indent by iterating in order and using >= on count update).
426    let indents: Vec<String> = elements.iter().map(leading_indent_of).collect();
427    let element_indent = predominant(&indents);
428
429    // Multi-line if any element starts on its own line (its leading trivia
430    // contains a newline) OR the collection text contains a newline between the
431    // open delimiter and the first element.
432    let multiline = indents.iter().any(|s| s.contains('\n'))
433        || located.text().contains('\n') && !indents.iter().all(String::is_empty);
434
435    // Trailing-comma convention: does a comma immediately follow the LAST element
436    // (ignoring trivia) before the closing delimiter? That is the predominant
437    // last-position convention.
438    let trailing_comma = last_element_has_trailing_comma(located, elements);
439
440    // Closing-delimiter indent: the whitespace before the closing delimiter, or
441    // one indent level shallower than an element indent.
442    let closing_indent = closing_indent_of(located, &element_indent, multiline);
443
444    CollectionStyle {
445        multiline,
446        element_indent,
447        closing_indent,
448        trailing_comma,
449    }
450}
451
452/// The leading whitespace run (after the last newline, if multi-line) of a node:
453/// the indent the next sibling should mirror.
454fn leading_indent_of(node: &SyntaxNode) -> String {
455    // Walk left siblings (tokens) collecting the run of whitespace immediately
456    // preceding this node, then take the text after the final newline.
457    let parent = match node.parent() {
458        Some(p) => p,
459        None => return String::new(),
460    };
461    let mut ws = String::new();
462    let target_range = node.text_range();
463    let mut prev_ws = String::new();
464    for el in parent.children_with_tokens() {
465        match el {
466            crate::syntax::SyntaxElement::Token(t) => {
467                if t.kind() == SyntaxKind::Whitespace {
468                    prev_ws = t.text().to_string();
469                } else {
470                    prev_ws.clear();
471                }
472            }
473            crate::syntax::SyntaxElement::Node(n) => {
474                if n.text_range() == target_range {
475                    ws = prev_ws.clone();
476                    break;
477                }
478                prev_ws.clear();
479            }
480        }
481    }
482    // Keep the indent on the element's own line: the text after the last newline.
483    match ws.rfind('\n') {
484        Some(i) => ws[i + 1..].to_string(),
485        None => {
486            if ws.contains('\n') {
487                ws
488            } else {
489                // Single-line: no per-line indent.
490                ws
491            }
492        }
493    }
494}
495
496/// The most-frequent string in `items`, ties broken toward the **last** item's
497/// value (FR-007 deterministic tie-break).
498fn predominant(items: &[String]) -> String {
499    if items.is_empty() {
500        return String::new();
501    }
502    let mut best = items[items.len() - 1].clone();
503    let mut best_count = 0usize;
504    // Iterate in order; use strict `>` so an earlier value only wins if it is
505    // strictly more frequent, leaving ties resolved toward the later (last) item
506    // which we seed as the initial best.
507    for candidate in items {
508        let count = items.iter().filter(|s| *s == candidate).count();
509        if count > best_count {
510            best_count = count;
511            best = candidate.clone();
512        }
513    }
514    best
515}
516
517/// Does a comma immediately follow the last element (ignoring trivia) before the
518/// closing delimiter?
519fn last_element_has_trailing_comma(located: &SyntaxNode, elements: &[SyntaxNode]) -> bool {
520    let Some(last) = elements.last() else {
521        return false;
522    };
523    let last_end = last.text_range().end();
524    // Find the first non-trivia token after the last element.
525    located
526        .children_with_tokens()
527        .filter_map(|el| match el {
528            crate::syntax::SyntaxElement::Token(t) => Some(t),
529            crate::syntax::SyntaxElement::Node(_) => None,
530        })
531        .filter(|t| t.text_range().start() >= last_end)
532        .find(|t| !t.is_trivia())
533        .map(|t| t.kind() == SyntaxKind::Comma)
534        .unwrap_or(false)
535}
536
537/// The indentation of the closing delimiter (the whitespace after the last
538/// newline preceding it), or a derived value when not multi-line.
539fn closing_indent_of(located: &SyntaxNode, element_indent: &str, multiline: bool) -> String {
540    if !multiline {
541        return String::new();
542    }
543    if let Some(close) = closing_delimiter(located) {
544        let close_start = close.text_range().start();
545        // The whitespace token immediately before the closing delimiter.
546        let prev_ws = located
547            .children_with_tokens()
548            .filter_map(|el| match el {
549                crate::syntax::SyntaxElement::Token(t) => Some(t),
550                crate::syntax::SyntaxElement::Node(_) => None,
551            })
552            .filter(|t| t.text_range().end() <= close_start && t.kind() == SyntaxKind::Whitespace)
553            .last();
554        if let Some(ws) = prev_ws {
555            if let Some(i) = ws.text().rfind('\n') {
556                return ws.text()[i + 1..].to_string();
557            }
558        }
559    }
560    // Fallback: one level shallower than the element indent (drop one unit, best
561    // effort by removing a 4-space / tab prefix).
562    derive_outer_indent(element_indent)
563}
564
565/// Best-effort one-level-shallower indent of `inner` (drop a trailing 4 spaces
566/// or one tab from the front).
567fn derive_outer_indent(inner: &str) -> String {
568    if let Some(stripped) = inner.strip_suffix("    ") {
569        stripped.to_string()
570    } else if let Some(stripped) = inner.strip_suffix('\t') {
571        stripped.to_string()
572    } else {
573        String::new()
574    }
575}
576
577/// Document default style for an empty collection (AD-005, FR-007): multi-line,
578/// document-detected indent (default 4 spaces), trailing-comma per document
579/// convention (default present).
580fn document_default_style(doc: &CstDocument, located: &SyntaxNode) -> CollectionStyle {
581    let unit = detect_document_indent_unit(doc);
582    // The indent of the empty collection's own opening line (best effort: indent
583    // of the line the collection sits on, plus one unit for its elements).
584    let base_indent = leading_indent_of(located);
585    let element_indent = format!("{base_indent}{unit}");
586    let trailing_comma = detect_document_trailing_comma(doc);
587    CollectionStyle {
588        multiline: true,
589        element_indent,
590        closing_indent: base_indent,
591        trailing_comma,
592    }
593}
594
595/// Detect the document's indentation unit from the first indented line; default
596/// to four spaces when none can be detected (FR-007).
597fn detect_document_indent_unit(doc: &CstDocument) -> String {
598    let text = crate::printer::print(doc);
599    for line in text.lines() {
600        let trimmed = line.trim_start_matches([' ', '\t']);
601        if trimmed.is_empty() || trimmed.len() == line.len() {
602            continue; // blank or unindented line
603        }
604        let indent = &line[..line.len() - trimmed.len()];
605        if !indent.is_empty() {
606            return indent.to_string();
607        }
608    }
609    "    ".to_string()
610}
611
612/// Detect the document's predominant trailing-comma convention; default to
613/// *present* when none can be detected (FR-007).
614fn detect_document_trailing_comma(doc: &CstDocument) -> bool {
615    // Scan every collection node: does its last element carry a trailing comma?
616    let mut present = 0usize;
617    let mut absent = 0usize;
618    fn walk(node: &SyntaxNode, present: &mut usize, absent: &mut usize) {
619        let elements: Vec<SyntaxNode> = match node.kind() {
620            SyntaxKind::Struct => ast::Struct::cast(node.clone())
621                .map(|s| s.fields().map(|f| f.syntax().clone()).collect())
622                .unwrap_or_default(),
623            SyntaxKind::Map => ast::Map::cast(node.clone())
624                .map(|m| m.entries().map(|e| e.syntax().clone()).collect())
625                .unwrap_or_default(),
626            SyntaxKind::List => ast::List::cast(node.clone())
627                .map(|l| l.items().map(|v| v.syntax().clone()).collect())
628                .unwrap_or_default(),
629            SyntaxKind::Tuple => ast::Tuple::cast(node.clone())
630                .map(|t| t.items().map(|v| v.syntax().clone()).collect())
631                .unwrap_or_default(),
632            _ => Vec::new(),
633        };
634        if !elements.is_empty() {
635            if last_element_has_trailing_comma(node, &elements) {
636                *present += 1;
637            } else {
638                *absent += 1;
639            }
640        }
641        for child in node.children() {
642            walk(&child, present, absent);
643        }
644    }
645    walk(&doc.root(), &mut present, &mut absent);
646    if present == 0 && absent == 0 {
647        true // document default: trailing-comma-present
648    } else {
649        present >= absent
650    }
651}
652
653// =============================================================================
654// Op implementations
655// =============================================================================
656
657/// Insert a `name: value` entry into a struct / map / enum-variant payload.
658fn insert_entry(
659    doc: &CstDocument,
660    parent: &ParentRef,
661    index: usize,
662    name: &str,
663    value: &str,
664) -> Result<CstDocument, BlockedReason> {
665    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
666    let elements = child_nodes(parent, &located);
667    let is_map = matches!(parent, ParentRef::Map(_));
668    let entry_text = format!("{name}: {value}");
669
670    insert_child_text(doc, &located, &elements, index, &entry_text, is_map)
671}
672
673/// Insert an element into a list / tuple at `index`.
674fn insert_element(
675    doc: &CstDocument,
676    parent: &ParentRef,
677    index: usize,
678    value: &str,
679) -> Result<CstDocument, BlockedReason> {
680    if !matches!(parent, ParentRef::List(_) | ParentRef::Tuple(_)) {
681        return Err(BlockedReason::InvalidPayload);
682    }
683    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
684    let elements = child_nodes(parent, &located);
685    insert_child_text(doc, &located, &elements, index, value, false)
686}
687
688/// Shared insertion: splice `child_text` (a fully-formed entry/element) at
689/// `index` among `elements`, adopting the collection's inferred style for
690/// indentation + separators (AD-005, FR-007). When inserting before an existing
691/// element we insert *before* that element's own leading trivia preserved; when
692/// appending we insert before the closing delimiter.
693fn insert_child_text(
694    doc: &CstDocument,
695    located: &SyntaxNode,
696    elements: &[SyntaxNode],
697    index: usize,
698    child_text: &str,
699    _is_map: bool,
700) -> Result<CstDocument, BlockedReason> {
701    let style = infer_style(doc, located, elements);
702    let idx = index.min(elements.len());
703
704    if idx < elements.len() {
705        // Insert before the element currently at `idx`.
706        let target = &elements[idx];
707        let payload = if style.multiline {
708            format!("{child_text},\n{}", style.element_indent)
709        } else {
710            format!("{child_text}, ")
711        };
712        let edit = EditOperation::insert(
713            EditTarget::Node(target.clone()),
714            payload,
715            TriviaPolicy::KEEP_ALL,
716        );
717        apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
718    } else {
719        // Append after the last element (or into an empty collection).
720        append_child_text(doc, located, elements, child_text, &style)
721    }
722}
723
724/// Append `child_text` as the last element/entry of a collection, adopting the
725/// inferred style. Inserts before the closing delimiter.
726fn append_child_text(
727    doc: &CstDocument,
728    located: &SyntaxNode,
729    elements: &[SyntaxNode],
730    child_text: &str,
731    style: &CollectionStyle,
732) -> Result<CstDocument, BlockedReason> {
733    let close = closing_delimiter(located).ok_or(BlockedReason::InvalidPayload)?;
734
735    if let Some(last) = elements.last() {
736        let has_trailing = last_element_has_trailing_comma(located, elements);
737        let payload = build_append_after_last(child_text, style, has_trailing);
738        if style.multiline {
739            // Multi-line: insert before the closing delimiter (which sits on its
740            // own line), so the new element lands on a fresh indented line and the
741            // closing line is preserved.
742            let edit = EditOperation::insert(
743                EditTarget::TokenSpan {
744                    first: close.clone(),
745                    last: close,
746                },
747                payload,
748                TriviaPolicy::KEEP_ALL,
749            );
750            apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
751        } else {
752            // Single-line: insert immediately after the last element's last token
753            // (before the first following token), so any trailing ` }` / ` ]`
754            // spacing before the close stays byte-identical.
755            let last_end = last.text_range().end();
756            let after = first_direct_token_at_or_after(located, last_end);
757            match after {
758                Some(tok) => {
759                    let edit = EditOperation::insert(
760                        EditTarget::TokenSpan {
761                            first: tok.clone(),
762                            last: tok,
763                        },
764                        payload,
765                        TriviaPolicy::KEEP_ALL,
766                    );
767                    apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
768                }
769                None => {
770                    let edit = EditOperation::insert(
771                        EditTarget::TokenSpan {
772                            first: close.clone(),
773                            last: close,
774                        },
775                        payload,
776                        TriviaPolicy::KEEP_ALL,
777                    );
778                    apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
779                }
780            }
781        }
782    } else {
783        // Empty collection: lay out the first element per the document default.
784        let payload = if style.multiline {
785            let comma = if style.trailing_comma { "," } else { "" };
786            format!(
787                "\n{}{child_text}{comma}\n{}",
788                style.element_indent, style.closing_indent
789            )
790        } else {
791            child_text.to_string()
792        };
793        let edit = EditOperation::insert(
794            EditTarget::TokenSpan {
795                first: close.clone(),
796                last: close,
797            },
798            payload,
799            TriviaPolicy::KEEP_ALL,
800        );
801        apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
802    }
803}
804
805/// Build the payload inserted before the closing delimiter when appending after
806/// an existing last element. The last element keeps its bytes; we add the new
807/// element with a leading separator that respects the trailing-comma convention.
808fn build_append_after_last(
809    child_text: &str,
810    style: &CollectionStyle,
811    last_has_trailing_comma: bool,
812) -> String {
813    if style.multiline {
814        // If the last element already has a trailing comma, the text before the
815        // close is `<elem>,\n<closing_indent>`. We want to land a new element on
816        // its own line. Insert before the close: `<elem_indent><new>,\n<close>`.
817        let new_trailing = if style.trailing_comma { "," } else { "" };
818        if last_has_trailing_comma {
819            format!(
820                "{}{child_text}{new_trailing}\n{}",
821                style.element_indent, style.closing_indent
822            )
823        } else {
824            // Last element has no trailing comma; add one to it then the new one.
825            format!(
826                ",\n{}{child_text}{new_trailing}\n{}",
827                style.element_indent, style.closing_indent
828            )
829        }
830    } else {
831        // Single-line, inserted immediately AFTER the last element's last token.
832        // Add the separator before the new element. When the last element already
833        // carries a trailing comma, only a space precedes the new element.
834        let new_trailing = if style.trailing_comma { "," } else { "" };
835        if last_has_trailing_comma {
836            format!(" {child_text}{new_trailing}")
837        } else {
838            format!(", {child_text}{new_trailing}")
839        }
840    }
841}
842
843/// The first direct-child token of `located` whose range starts at or after
844/// `offset` (the token immediately following an element).
845fn first_direct_token_at_or_after(located: &SyntaxNode, offset: usize) -> Option<SyntaxToken> {
846    located
847        .children_with_tokens()
848        .filter_map(|el| el.as_token().cloned())
849        .find(|t| t.text_range().start() >= offset)
850}
851
852/// Remove the child at `index` from a parent, taking only the child + its own
853/// trivia and normalizing the trailing-comma separator (FR-021).
854///
855/// Composed as two non-destructive edits over [`apply_edit`], producing one new
856/// CST: first the separator run (comma + adjacent same-line trivia, all *direct
857/// child tokens* of the parent), then the element node itself (with its leading
858/// whitespace absorbed). All separator pieces are direct-child tokens, so each
859/// step is a well-formed [`EditTarget`]; the element is re-resolved by index
860/// between the two steps against the intermediate tree.
861fn remove_child(
862    doc: &CstDocument,
863    parent: &ParentRef,
864    index: usize,
865) -> Result<CstDocument, BlockedReason> {
866    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
867    let elements = child_nodes(parent, &located);
868    if index >= elements.len() {
869        return Err(BlockedReason::TargetNotFound);
870    }
871    let is_last = index + 1 == elements.len();
872    let sole = elements.len() == 1;
873    let last_trailing_comma = last_element_has_trailing_comma(&located, &elements);
874
875    if !is_last {
876        // --- Non-last element ---------------------------------------------
877        // Delete: [element][following comma][following trivia up to and
878        // INCLUDING the newline-indent that leads element i+1]; KEEP element i's
879        // own leading trivia, which becomes element i+1's new leading run. This
880        // collapses to one clean separator for both single-line and multi-line.
881        let after_sep = remove_following_separator(doc, &located, &elements, index)?;
882        let located2 = locate_parent(&after_sep, parent).ok_or(BlockedReason::TargetNotFound)?;
883        let elements2 = child_nodes(parent, &located2);
884        let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
885        let edit = EditOperation::remove(
886            EditTarget::Node(target.clone()),
887            TriviaPolicy {
888                keep_leading: true,
889                keep_trailing: true,
890            },
891        );
892        apply_edit(&after_sep, edit).map_err(|_| BlockedReason::TargetNotFound)
893    } else if sole {
894        // --- Sole element -------------------------------------------------
895        // Remove the element node; KEEP surrounding trivia so an empty
896        // collection retains its delimiters' own layout (lossless).
897        let edit = EditOperation::remove(
898            EditTarget::Node(elements[index].clone()),
899            TriviaPolicy::KEEP_ALL,
900        );
901        apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
902    } else if last_trailing_comma {
903        // --- Last element WITH a trailing comma (multi-line style) --------
904        // Keep the previous element's comma; delete element i's leading trivia +
905        // element + its trailing comma + same-line trivia, keeping the final
906        // newline before the closing delimiter.
907        let after_trail = remove_trailing_comma_run(doc, &located, &elements, index)?;
908        let located2 = locate_parent(&after_trail, parent).ok_or(BlockedReason::TargetNotFound)?;
909        let elements2 = child_nodes(parent, &located2);
910        let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
911        let edit = EditOperation::remove(
912            EditTarget::Node(target.clone()),
913            TriviaPolicy {
914                keep_leading: false,
915                keep_trailing: true,
916            },
917        );
918        apply_edit(&after_trail, edit).map_err(|_| BlockedReason::TargetNotFound)
919    } else {
920        // --- Last element WITHOUT a trailing comma (single-line style) ----
921        // Delete the preceding comma + trivia, then the element + its leading.
922        let after_sep = remove_preceding_separator(doc, &located, &elements, index)?;
923        let located2 = locate_parent(&after_sep, parent).ok_or(BlockedReason::TargetNotFound)?;
924        let elements2 = child_nodes(parent, &located2);
925        let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
926        let edit = EditOperation::remove(
927            EditTarget::Node(target.clone()),
928            TriviaPolicy {
929                keep_leading: false,
930                keep_trailing: true,
931            },
932        );
933        apply_edit(&after_sep, edit).map_err(|_| BlockedReason::TargetNotFound)
934    }
935}
936
937/// Remove the separator run *following* element `index` (the comma and **all**
938/// trivia up to the next element's first token), as a direct-child token span.
939/// Element i's own leading trivia is kept and becomes element i+1's leading run.
940fn remove_following_separator(
941    doc: &CstDocument,
942    located: &SyntaxNode,
943    elements: &[SyntaxNode],
944    index: usize,
945) -> Result<CstDocument, BlockedReason> {
946    let between = direct_tokens_between(
947        located,
948        elements[index].text_range().end(),
949        elements[index + 1].text_range().start(),
950    );
951    remove_token_run(doc, &between)
952}
953
954/// Remove the separator run *preceding* element `index` (the comma + trivia back
955/// to the previous element's end), as a direct-child token span. For `index == 0`
956/// there is nothing to remove.
957fn remove_preceding_separator(
958    doc: &CstDocument,
959    located: &SyntaxNode,
960    elements: &[SyntaxNode],
961    index: usize,
962) -> Result<CstDocument, BlockedReason> {
963    if index == 0 {
964        return Ok(doc.clone());
965    }
966    let between = direct_tokens_between(
967        located,
968        elements[index - 1].text_range().end(),
969        elements[index].text_range().start(),
970    );
971    remove_token_run(doc, &between)
972}
973
974/// Remove the trailing-comma run that *follows* the last element `index` (its
975/// comma + same-line trivia), keeping the final newline-indent before the closing
976/// delimiter so the collection's closing line stays put.
977fn remove_trailing_comma_run(
978    doc: &CstDocument,
979    located: &SyntaxNode,
980    elements: &[SyntaxNode],
981    index: usize,
982) -> Result<CstDocument, BlockedReason> {
983    let elem_end = elements[index].text_range().end();
984    let close_start = closing_delimiter(located)
985        .map(|t| t.text_range().start())
986        .unwrap_or_else(|| located.text_range().end());
987    let between = direct_tokens_between(located, elem_end, close_start);
988    // Keep a trailing newline-ws run (it leads the closing delimiter's line).
989    let mut last_idx = between.len();
990    while last_idx > 0 {
991        let t = &between[last_idx - 1];
992        if t.kind() == SyntaxKind::Whitespace && t.text().contains('\n') {
993            last_idx -= 1;
994        } else {
995            break;
996        }
997    }
998    remove_token_run(doc, &between[..last_idx])
999}
1000
1001/// Direct-child tokens of `located` whose ranges fall within `[lo, hi)`.
1002fn direct_tokens_between(located: &SyntaxNode, lo: usize, hi: usize) -> Vec<SyntaxToken> {
1003    located
1004        .children_with_tokens()
1005        .filter_map(|el| el.as_token().cloned())
1006        .filter(|t| t.text_range().start() >= lo && t.text_range().end() <= hi)
1007        .collect()
1008}
1009
1010/// Remove a contiguous run of direct-child tokens via one [`apply_edit`]; a no-op
1011/// (returns the document unchanged) when the run is empty.
1012fn remove_token_run(doc: &CstDocument, run: &[SyntaxToken]) -> Result<CstDocument, BlockedReason> {
1013    let Some(first) = run.first().cloned() else {
1014        return Ok(doc.clone());
1015    };
1016    let last = run[run.len() - 1].clone();
1017    let edit = EditOperation::remove(
1018        EditTarget::TokenSpan { first, last },
1019        TriviaPolicy::KEEP_ALL,
1020    );
1021    apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
1022}
1023
1024/// Rename the key/field at `index` to `new_name`, blocking on a same-parent
1025/// collision (FR-003).
1026fn rename_key(
1027    doc: &CstDocument,
1028    parent: &ParentRef,
1029    index: usize,
1030    new_name: &str,
1031) -> Result<CstDocument, BlockedReason> {
1032    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
1033    let elements = child_nodes(parent, &located);
1034    let target = elements.get(index).ok_or(BlockedReason::TargetNotFound)?;
1035
1036    // Collision check: does any *other* entry already use `new_name`?
1037    for (i, el) in elements.iter().enumerate() {
1038        if i == index {
1039            continue;
1040        }
1041        if entry_key_text(parent, el).as_deref() == Some(new_name) {
1042            return Err(BlockedReason::RenameCollision);
1043        }
1044    }
1045
1046    // The key token to replace. A struct field's key is its name `Ident`. A map
1047    // entry — and an enum-variant struct-payload entry, which parses as a
1048    // `MapEntry` whose key is a bare-ident value — replaces the whole key node.
1049    let key_target = match parent {
1050        ParentRef::Struct(_) => {
1051            let field =
1052                ast::StructField::cast(target.clone()).ok_or(BlockedReason::InvalidPayload)?;
1053            let name = field.name().ok_or(BlockedReason::InvalidPayload)?;
1054            EditTarget::TokenSpan {
1055                first: name.clone(),
1056                last: name,
1057            }
1058        }
1059        ParentRef::Map(_) | ParentRef::EnumVariant(_) => {
1060            let entry = ast::MapEntry::cast(target.clone()).ok_or(BlockedReason::InvalidPayload)?;
1061            let key = entry.key().ok_or(BlockedReason::InvalidPayload)?;
1062            // Replace the whole key value node (covers literal/ident keys).
1063            EditTarget::Node(key.syntax().clone())
1064        }
1065        _ => return Err(BlockedReason::InvalidPayload),
1066    };
1067
1068    let edit = EditOperation::replace(key_target, new_name.to_string(), TriviaPolicy::KEEP_ALL);
1069    apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
1070}
1071
1072/// The verbatim key text of a [`ast::MapEntry`] (its key value's source text).
1073fn map_entry_key_text(entry: &ast::MapEntry) -> Option<String> {
1074    entry.key().map(|k| k.syntax().text())
1075}
1076
1077/// The key text of an entry (struct field name / map key literal / enum-variant
1078/// payload field name), for collision comparison.
1079fn entry_key_text(parent: &ParentRef, entry: &SyntaxNode) -> Option<String> {
1080    match parent {
1081        ParentRef::Struct(_) => ast::StructField::cast(entry.clone()).and_then(|f| f.name_text()),
1082        ParentRef::Map(_) | ParentRef::EnumVariant(_) => ast::MapEntry::cast(entry.clone())
1083            .and_then(|e| e.key())
1084            .map(|k| k.syntax().text()),
1085        _ => None,
1086    }
1087}
1088
1089/// Replace the value of an entry / element at `index` with `value` text.
1090fn set_value(
1091    doc: &CstDocument,
1092    parent: &ParentRef,
1093    index: usize,
1094    value: &str,
1095) -> Result<CstDocument, BlockedReason> {
1096    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
1097    let elements = child_nodes(parent, &located);
1098    let target = elements.get(index).ok_or(BlockedReason::TargetNotFound)?;
1099
1100    let value_node = match parent {
1101        ParentRef::Struct(_) => ast::StructField::cast(target.clone())
1102            .and_then(|f| f.value())
1103            .map(|v| v.syntax().clone()),
1104        ParentRef::Map(_) | ParentRef::EnumVariant(_) => ast::MapEntry::cast(target.clone())
1105            .and_then(|e| e.value())
1106            .map(|v| v.syntax().clone()),
1107        // For list/tuple the element node *is* the value.
1108        ParentRef::List(_) | ParentRef::Tuple(_) => Some(target.clone()),
1109    }
1110    .ok_or(BlockedReason::InvalidPayload)?;
1111
1112    let edit = EditOperation::replace(
1113        EditTarget::Node(value_node),
1114        value.to_string(),
1115        TriviaPolicy::KEEP_ALL,
1116    );
1117    apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
1118}
1119
1120/// Reorder: move the child at `from` to position `to` within the parent, composed
1121/// as remove + re-insert producing one new CST (FR-003).
1122fn reorder_child(
1123    doc: &CstDocument,
1124    parent: &ParentRef,
1125    from: usize,
1126    to: usize,
1127) -> Result<CstDocument, BlockedReason> {
1128    let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
1129    let elements = child_nodes(parent, &located);
1130    if from >= elements.len() || to >= elements.len() {
1131        return Err(BlockedReason::TargetNotFound);
1132    }
1133    if from == to {
1134        // No-op move: return an identical (re-rooted) document so the op is still
1135        // "applied" but byte-unchanged.
1136        return Ok(doc.clone());
1137    }
1138
1139    // Capture the moved element's verbatim text (its own bytes, preserved).
1140    let moved_text = elements[from].text();
1141
1142    // Step 1: remove the element at `from`.
1143    let after_remove = remove_child(doc, parent, from)?;
1144
1145    // Step 2: re-resolve the parent against the new tree and insert the captured
1146    // text at the destination index (adjusted for the removal).
1147    let located2 = locate_parent(&after_remove, parent).ok_or(BlockedReason::TargetNotFound)?;
1148    let elements2 = child_nodes(parent, &located2);
1149    // Post-removal insertion index that lands the moved element at FINAL index
1150    // `to`. Removing `from` shifts every later element down by one, so inserting
1151    // at `to` (clamped to append) places it at final position `to` in both the
1152    // `from < to` and `from > to` directions.
1153    let dest = to.min(elements2.len());
1154
1155    match parent {
1156        ParentRef::List(_) | ParentRef::Tuple(_) => insert_child_text(
1157            &after_remove,
1158            &located2,
1159            &elements2,
1160            dest,
1161            &moved_text,
1162            false,
1163        ),
1164        ParentRef::Struct(_) | ParentRef::Map(_) | ParentRef::EnumVariant(_) => {
1165            let is_map = matches!(parent, ParentRef::Map(_));
1166            insert_child_text(
1167                &after_remove,
1168                &located2,
1169                &elements2,
1170                dest,
1171                &moved_text,
1172                is_map,
1173            )
1174        }
1175    }
1176}
1177
1178/// Swap an enum variant's name + field set in place (FR-003).
1179fn swap_enum_variant(
1180    doc: &CstDocument,
1181    variant: &SyntaxNode,
1182    new_name: &str,
1183    new_fields: &[String],
1184    placeholder: &str,
1185) -> Result<CstDocument, BlockedReason> {
1186    let located = locate_node(doc, variant).ok_or(BlockedReason::TargetNotFound)?;
1187    let variant_ast =
1188        ast::EnumVariant::cast(located.clone()).ok_or(BlockedReason::InvalidPayload)?;
1189
1190    // Existing field names of the struct-like payload (entries parse as
1191    // `MapEntry`s whose key is a bare-ident value).
1192    let old_names: Vec<String> = variant_ast
1193        .entries()
1194        .filter_map(|e| map_entry_key_text(&e))
1195        .collect();
1196
1197    // 1) Rename the variant name token.
1198    let name_tok = variant_ast.name().ok_or(BlockedReason::InvalidPayload)?;
1199    let mut current = apply_edit(
1200        doc,
1201        EditOperation::replace(
1202            EditTarget::TokenSpan {
1203                first: name_tok.clone(),
1204                last: name_tok,
1205            },
1206            new_name.to_string(),
1207            TriviaPolicy::KEEP_ALL,
1208        ),
1209    )
1210    .map_err(|_| BlockedReason::TargetNotFound)?;
1211
1212    let parent_ref = ParentRef::EnumVariant(located.clone());
1213
1214    // 2) Remove old-only fields (those not in `new_fields`). Remove from the end
1215    // backward so earlier indices stay valid across removals.
1216    let remove_names: Vec<String> = old_names
1217        .iter()
1218        .filter(|n| !new_fields.contains(n))
1219        .cloned()
1220        .collect();
1221    for name in remove_names.iter().rev() {
1222        // Re-resolve indices each iteration against the live tree.
1223        let located_now =
1224            locate_parent(&current, &parent_ref).ok_or(BlockedReason::TargetNotFound)?;
1225        let entries = child_nodes(&parent_ref, &located_now);
1226        if let Some(pos) = entries.iter().position(|e| {
1227            ast::MapEntry::cast(e.clone())
1228                .as_ref()
1229                .and_then(map_entry_key_text)
1230                .as_deref()
1231                == Some(name)
1232        }) {
1233            current = remove_child(&current, &parent_ref, pos)?;
1234        }
1235    }
1236
1237    // 3) Add new-only fields with the placeholder value, in `new_fields` order.
1238    for name in new_fields {
1239        if old_names.contains(name) {
1240            continue; // shared field: keep existing value/bytes.
1241        }
1242        let located_now =
1243            locate_parent(&current, &parent_ref).ok_or(BlockedReason::TargetNotFound)?;
1244        let entries = child_nodes(&parent_ref, &located_now);
1245        let at = entries.len();
1246        current = insert_entry(&current, &parent_ref, at, name, placeholder)?;
1247    }
1248
1249    Ok(current)
1250}
1251
1252/// Add `name: value` to every struct element of `list`, batched into one
1253/// transform (ADR-0007 multi-node op).
1254fn add_field_across_rows(
1255    doc: &CstDocument,
1256    list: &SyntaxNode,
1257    name: &str,
1258    value: &str,
1259) -> Result<CstDocument, BlockedReason> {
1260    let located = locate_node(doc, list).ok_or(BlockedReason::TargetNotFound)?;
1261    if ast::List::cast(located.clone()).is_none() {
1262        return Err(BlockedReason::InvalidPayload);
1263    }
1264
1265    // Number of struct rows to touch (captured up-front; we re-resolve each).
1266    let row_count = ast::List::cast(located.clone())
1267        .map(|l| {
1268            l.items()
1269                .filter(|v| matches!(v, ast::Value::Struct(_)))
1270                .count()
1271        })
1272        .unwrap_or(0);
1273
1274    let mut current = doc.clone();
1275    for row_idx in 0..row_count {
1276        // Re-resolve the list against the live tree each iteration.
1277        let located_now = locate_node(&current, list).ok_or(BlockedReason::TargetNotFound)?;
1278        let list_ast = ast::List::cast(located_now.clone()).ok_or(BlockedReason::InvalidPayload)?;
1279        let struct_nodes: Vec<SyntaxNode> = list_ast
1280            .items()
1281            .filter_map(|v| match v {
1282                ast::Value::Struct(s) => Some(s.syntax().clone()),
1283                _ => None,
1284            })
1285            .collect();
1286        let Some(struct_node) = struct_nodes.get(row_idx) else {
1287            break;
1288        };
1289        let parent_ref = ParentRef::Struct(struct_node.clone());
1290        let entries = child_nodes(&parent_ref, struct_node);
1291        let at = entries.len();
1292        current = insert_entry(&current, &parent_ref, at, name, value)?;
1293    }
1294
1295    Ok(current)
1296}