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(¤t, &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(¤t, &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(¤t, &parent_ref).ok_or(BlockedReason::TargetNotFound)?;
1244 let entries = child_nodes(&parent_ref, &located_now);
1245 let at = entries.len();
1246 current = insert_entry(¤t, &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(¤t, 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(¤t, &parent_ref, at, name, value)?;
1293 }
1294
1295 Ok(current)
1296}