use crate::edit::{apply_edit, EditOperation, EditTarget, TriviaPolicy};
use crate::parser::CstDocument;
use crate::syntax::ast;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParentRef {
Struct(SyntaxNode),
Map(SyntaxNode),
List(SyntaxNode),
Tuple(SyntaxNode),
EnumVariant(SyntaxNode),
}
impl ParentRef {
#[must_use]
pub fn node(&self) -> &SyntaxNode {
match self {
Self::Struct(n)
| Self::Map(n)
| Self::List(n)
| Self::Tuple(n)
| Self::EnumVariant(n) => n,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StructuralOp {
InsertField {
parent: ParentRef,
index: usize,
name: String,
value: String,
},
RemoveField {
parent: ParentRef,
index: usize,
},
RenameKey {
parent: ParentRef,
index: usize,
new_name: String,
},
ReorderChild {
parent: ParentRef,
from: usize,
to: usize,
},
SetValue {
parent: ParentRef,
index: usize,
value: String,
},
InsertElement {
parent: ParentRef,
index: usize,
value: String,
},
RemoveElement {
parent: ParentRef,
index: usize,
},
SwapEnumVariant {
variant: SyntaxNode,
new_name: String,
new_fields: Vec<String>,
placeholder: String,
},
AddFieldAcrossRows {
list: SyntaxNode,
name: String,
value: String,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum TransformOutcome {
Applied(CstDocument),
Blocked(BlockedReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BlockedReason {
RenameCollision,
TargetNotFound,
InvalidPayload,
}
#[must_use]
pub fn apply_structural(doc: &CstDocument, op: StructuralOp) -> TransformOutcome {
let result = match op {
StructuralOp::InsertField {
parent,
index,
name,
value,
} => insert_entry(doc, &parent, index, &name, &value),
StructuralOp::RemoveField { parent, index } => remove_child(doc, &parent, index),
StructuralOp::RenameKey {
parent,
index,
new_name,
} => rename_key(doc, &parent, index, &new_name),
StructuralOp::ReorderChild { parent, from, to } => reorder_child(doc, &parent, from, to),
StructuralOp::SetValue {
parent,
index,
value,
} => set_value(doc, &parent, index, &value),
StructuralOp::InsertElement {
parent,
index,
value,
} => insert_element(doc, &parent, index, &value),
StructuralOp::RemoveElement { parent, index } => remove_child(doc, &parent, index),
StructuralOp::SwapEnumVariant {
variant,
new_name,
new_fields,
placeholder,
} => swap_enum_variant(doc, &variant, &new_name, &new_fields, &placeholder),
StructuralOp::AddFieldAcrossRows { list, name, value } => {
add_field_across_rows(doc, &list, &name, &value)
}
};
match result {
Ok(new_doc) => TransformOutcome::Applied(new_doc),
Err(reason) => TransformOutcome::Blocked(reason),
}
}
fn locate_parent(doc: &CstDocument, parent: &ParentRef) -> Option<SyntaxNode> {
let want = parent.node();
find_node(&doc.root(), want.kind(), want.text_range().start())
}
fn locate_node(doc: &CstDocument, node: &SyntaxNode) -> Option<SyntaxNode> {
find_node(&doc.root(), node.kind(), node.text_range().start())
}
fn find_node(root: &SyntaxNode, kind: SyntaxKind, start: usize) -> Option<SyntaxNode> {
fn walk(node: &SyntaxNode, kind: SyntaxKind, start: usize, out: &mut Option<SyntaxNode>) {
if out.is_some() {
return;
}
if node.kind() == kind && node.text_range().start() == start {
*out = Some(node.clone());
return;
}
for child in node.children() {
let cr = child.text_range();
if cr.start() <= start && start < cr.end() {
walk(&child, kind, start, out);
}
}
}
let mut out = None;
walk(root, kind, start, &mut out);
out
}
fn child_nodes(parent: &ParentRef, located: &SyntaxNode) -> Vec<SyntaxNode> {
match parent {
ParentRef::Struct(_) => ast::Struct::cast(located.clone())
.map(|s| s.fields().map(|f| f.syntax().clone()).collect())
.unwrap_or_default(),
ParentRef::Map(_) => ast::Map::cast(located.clone())
.map(|m| m.entries().map(|e| e.syntax().clone()).collect())
.unwrap_or_default(),
ParentRef::EnumVariant(_) => ast::EnumVariant::cast(located.clone())
.map(|v| v.entries().map(|e| e.syntax().clone()).collect())
.unwrap_or_default(),
ParentRef::List(_) => ast::List::cast(located.clone())
.map(|l| l.items().map(|v| v.syntax().clone()).collect())
.unwrap_or_default(),
ParentRef::Tuple(_) => ast::Tuple::cast(located.clone())
.map(|t| t.items().map(|v| v.syntax().clone()).collect())
.unwrap_or_default(),
}
}
fn closing_delimiter(node: &SyntaxNode) -> Option<SyntaxToken> {
node.children_with_tokens()
.filter_map(|el| el.as_token().cloned())
.filter(|t| {
matches!(
t.kind(),
SyntaxKind::RParen | SyntaxKind::RBracket | SyntaxKind::RBrace
)
})
.last()
}
#[derive(Debug, Clone)]
struct CollectionStyle {
multiline: bool,
element_indent: String,
closing_indent: String,
trailing_comma: bool,
}
fn infer_style(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
) -> CollectionStyle {
if elements.is_empty() {
return document_default_style(doc, located);
}
let indents: Vec<String> = elements.iter().map(leading_indent_of).collect();
let element_indent = predominant(&indents);
let multiline = indents.iter().any(|s| s.contains('\n'))
|| located.text().contains('\n') && !indents.iter().all(String::is_empty);
let trailing_comma = last_element_has_trailing_comma(located, elements);
let closing_indent = closing_indent_of(located, &element_indent, multiline);
CollectionStyle {
multiline,
element_indent,
closing_indent,
trailing_comma,
}
}
fn leading_indent_of(node: &SyntaxNode) -> String {
let parent = match node.parent() {
Some(p) => p,
None => return String::new(),
};
let mut ws = String::new();
let target_range = node.text_range();
let mut prev_ws = String::new();
for el in parent.children_with_tokens() {
match el {
crate::syntax::SyntaxElement::Token(t) => {
if t.kind() == SyntaxKind::Whitespace {
prev_ws = t.text().to_string();
} else {
prev_ws.clear();
}
}
crate::syntax::SyntaxElement::Node(n) => {
if n.text_range() == target_range {
ws = prev_ws.clone();
break;
}
prev_ws.clear();
}
}
}
match ws.rfind('\n') {
Some(i) => ws[i + 1..].to_string(),
None => {
if ws.contains('\n') {
ws
} else {
ws
}
}
}
}
fn predominant(items: &[String]) -> String {
if items.is_empty() {
return String::new();
}
let mut best = items[items.len() - 1].clone();
let mut best_count = 0usize;
for candidate in items {
let count = items.iter().filter(|s| *s == candidate).count();
if count > best_count {
best_count = count;
best = candidate.clone();
}
}
best
}
fn last_element_has_trailing_comma(located: &SyntaxNode, elements: &[SyntaxNode]) -> bool {
let Some(last) = elements.last() else {
return false;
};
let last_end = last.text_range().end();
located
.children_with_tokens()
.filter_map(|el| match el {
crate::syntax::SyntaxElement::Token(t) => Some(t),
crate::syntax::SyntaxElement::Node(_) => None,
})
.filter(|t| t.text_range().start() >= last_end)
.find(|t| !t.is_trivia())
.map(|t| t.kind() == SyntaxKind::Comma)
.unwrap_or(false)
}
fn closing_indent_of(located: &SyntaxNode, element_indent: &str, multiline: bool) -> String {
if !multiline {
return String::new();
}
if let Some(close) = closing_delimiter(located) {
let close_start = close.text_range().start();
let prev_ws = located
.children_with_tokens()
.filter_map(|el| match el {
crate::syntax::SyntaxElement::Token(t) => Some(t),
crate::syntax::SyntaxElement::Node(_) => None,
})
.filter(|t| t.text_range().end() <= close_start && t.kind() == SyntaxKind::Whitespace)
.last();
if let Some(ws) = prev_ws {
if let Some(i) = ws.text().rfind('\n') {
return ws.text()[i + 1..].to_string();
}
}
}
derive_outer_indent(element_indent)
}
fn derive_outer_indent(inner: &str) -> String {
if let Some(stripped) = inner.strip_suffix(" ") {
stripped.to_string()
} else if let Some(stripped) = inner.strip_suffix('\t') {
stripped.to_string()
} else {
String::new()
}
}
fn document_default_style(doc: &CstDocument, located: &SyntaxNode) -> CollectionStyle {
let unit = detect_document_indent_unit(doc);
let base_indent = leading_indent_of(located);
let element_indent = format!("{base_indent}{unit}");
let trailing_comma = detect_document_trailing_comma(doc);
CollectionStyle {
multiline: true,
element_indent,
closing_indent: base_indent,
trailing_comma,
}
}
fn detect_document_indent_unit(doc: &CstDocument) -> String {
let text = crate::printer::print(doc);
for line in text.lines() {
let trimmed = line.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.len() == line.len() {
continue; }
let indent = &line[..line.len() - trimmed.len()];
if !indent.is_empty() {
return indent.to_string();
}
}
" ".to_string()
}
fn detect_document_trailing_comma(doc: &CstDocument) -> bool {
let mut present = 0usize;
let mut absent = 0usize;
fn walk(node: &SyntaxNode, present: &mut usize, absent: &mut usize) {
let elements: Vec<SyntaxNode> = match node.kind() {
SyntaxKind::Struct => ast::Struct::cast(node.clone())
.map(|s| s.fields().map(|f| f.syntax().clone()).collect())
.unwrap_or_default(),
SyntaxKind::Map => ast::Map::cast(node.clone())
.map(|m| m.entries().map(|e| e.syntax().clone()).collect())
.unwrap_or_default(),
SyntaxKind::List => ast::List::cast(node.clone())
.map(|l| l.items().map(|v| v.syntax().clone()).collect())
.unwrap_or_default(),
SyntaxKind::Tuple => ast::Tuple::cast(node.clone())
.map(|t| t.items().map(|v| v.syntax().clone()).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
if !elements.is_empty() {
if last_element_has_trailing_comma(node, &elements) {
*present += 1;
} else {
*absent += 1;
}
}
for child in node.children() {
walk(&child, present, absent);
}
}
walk(&doc.root(), &mut present, &mut absent);
if present == 0 && absent == 0 {
true } else {
present >= absent
}
}
fn insert_entry(
doc: &CstDocument,
parent: &ParentRef,
index: usize,
name: &str,
value: &str,
) -> Result<CstDocument, BlockedReason> {
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
let is_map = matches!(parent, ParentRef::Map(_));
let entry_text = format!("{name}: {value}");
insert_child_text(doc, &located, &elements, index, &entry_text, is_map)
}
fn insert_element(
doc: &CstDocument,
parent: &ParentRef,
index: usize,
value: &str,
) -> Result<CstDocument, BlockedReason> {
if !matches!(parent, ParentRef::List(_) | ParentRef::Tuple(_)) {
return Err(BlockedReason::InvalidPayload);
}
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
insert_child_text(doc, &located, &elements, index, value, false)
}
fn insert_child_text(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
index: usize,
child_text: &str,
_is_map: bool,
) -> Result<CstDocument, BlockedReason> {
let style = infer_style(doc, located, elements);
let idx = index.min(elements.len());
if idx < elements.len() {
let target = &elements[idx];
let payload = if style.multiline {
format!("{child_text},\n{}", style.element_indent)
} else {
format!("{child_text}, ")
};
let edit = EditOperation::insert(
EditTarget::Node(target.clone()),
payload,
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
} else {
append_child_text(doc, located, elements, child_text, &style)
}
}
fn append_child_text(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
child_text: &str,
style: &CollectionStyle,
) -> Result<CstDocument, BlockedReason> {
let close = closing_delimiter(located).ok_or(BlockedReason::InvalidPayload)?;
if let Some(last) = elements.last() {
let has_trailing = last_element_has_trailing_comma(located, elements);
let payload = build_append_after_last(child_text, style, has_trailing);
if style.multiline {
let edit = EditOperation::insert(
EditTarget::TokenSpan {
first: close.clone(),
last: close,
},
payload,
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
} else {
let last_end = last.text_range().end();
let after = first_direct_token_at_or_after(located, last_end);
match after {
Some(tok) => {
let edit = EditOperation::insert(
EditTarget::TokenSpan {
first: tok.clone(),
last: tok,
},
payload,
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
None => {
let edit = EditOperation::insert(
EditTarget::TokenSpan {
first: close.clone(),
last: close,
},
payload,
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
}
}
} else {
let payload = if style.multiline {
let comma = if style.trailing_comma { "," } else { "" };
format!(
"\n{}{child_text}{comma}\n{}",
style.element_indent, style.closing_indent
)
} else {
child_text.to_string()
};
let edit = EditOperation::insert(
EditTarget::TokenSpan {
first: close.clone(),
last: close,
},
payload,
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
}
fn build_append_after_last(
child_text: &str,
style: &CollectionStyle,
last_has_trailing_comma: bool,
) -> String {
if style.multiline {
let new_trailing = if style.trailing_comma { "," } else { "" };
if last_has_trailing_comma {
format!(
"{}{child_text}{new_trailing}\n{}",
style.element_indent, style.closing_indent
)
} else {
format!(
",\n{}{child_text}{new_trailing}\n{}",
style.element_indent, style.closing_indent
)
}
} else {
let new_trailing = if style.trailing_comma { "," } else { "" };
if last_has_trailing_comma {
format!(" {child_text}{new_trailing}")
} else {
format!(", {child_text}{new_trailing}")
}
}
}
fn first_direct_token_at_or_after(located: &SyntaxNode, offset: usize) -> Option<SyntaxToken> {
located
.children_with_tokens()
.filter_map(|el| el.as_token().cloned())
.find(|t| t.text_range().start() >= offset)
}
fn remove_child(
doc: &CstDocument,
parent: &ParentRef,
index: usize,
) -> Result<CstDocument, BlockedReason> {
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
if index >= elements.len() {
return Err(BlockedReason::TargetNotFound);
}
let is_last = index + 1 == elements.len();
let sole = elements.len() == 1;
let last_trailing_comma = last_element_has_trailing_comma(&located, &elements);
if !is_last {
let after_sep = remove_following_separator(doc, &located, &elements, index)?;
let located2 = locate_parent(&after_sep, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements2 = child_nodes(parent, &located2);
let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
let edit = EditOperation::remove(
EditTarget::Node(target.clone()),
TriviaPolicy {
keep_leading: true,
keep_trailing: true,
},
);
apply_edit(&after_sep, edit).map_err(|_| BlockedReason::TargetNotFound)
} else if sole {
let edit = EditOperation::remove(
EditTarget::Node(elements[index].clone()),
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
} else if last_trailing_comma {
let after_trail = remove_trailing_comma_run(doc, &located, &elements, index)?;
let located2 = locate_parent(&after_trail, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements2 = child_nodes(parent, &located2);
let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
let edit = EditOperation::remove(
EditTarget::Node(target.clone()),
TriviaPolicy {
keep_leading: false,
keep_trailing: true,
},
);
apply_edit(&after_trail, edit).map_err(|_| BlockedReason::TargetNotFound)
} else {
let after_sep = remove_preceding_separator(doc, &located, &elements, index)?;
let located2 = locate_parent(&after_sep, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements2 = child_nodes(parent, &located2);
let target = elements2.get(index).ok_or(BlockedReason::TargetNotFound)?;
let edit = EditOperation::remove(
EditTarget::Node(target.clone()),
TriviaPolicy {
keep_leading: false,
keep_trailing: true,
},
);
apply_edit(&after_sep, edit).map_err(|_| BlockedReason::TargetNotFound)
}
}
fn remove_following_separator(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
index: usize,
) -> Result<CstDocument, BlockedReason> {
let between = direct_tokens_between(
located,
elements[index].text_range().end(),
elements[index + 1].text_range().start(),
);
remove_token_run(doc, &between)
}
fn remove_preceding_separator(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
index: usize,
) -> Result<CstDocument, BlockedReason> {
if index == 0 {
return Ok(doc.clone());
}
let between = direct_tokens_between(
located,
elements[index - 1].text_range().end(),
elements[index].text_range().start(),
);
remove_token_run(doc, &between)
}
fn remove_trailing_comma_run(
doc: &CstDocument,
located: &SyntaxNode,
elements: &[SyntaxNode],
index: usize,
) -> Result<CstDocument, BlockedReason> {
let elem_end = elements[index].text_range().end();
let close_start = closing_delimiter(located)
.map(|t| t.text_range().start())
.unwrap_or_else(|| located.text_range().end());
let between = direct_tokens_between(located, elem_end, close_start);
let mut last_idx = between.len();
while last_idx > 0 {
let t = &between[last_idx - 1];
if t.kind() == SyntaxKind::Whitespace && t.text().contains('\n') {
last_idx -= 1;
} else {
break;
}
}
remove_token_run(doc, &between[..last_idx])
}
fn direct_tokens_between(located: &SyntaxNode, lo: usize, hi: usize) -> Vec<SyntaxToken> {
located
.children_with_tokens()
.filter_map(|el| el.as_token().cloned())
.filter(|t| t.text_range().start() >= lo && t.text_range().end() <= hi)
.collect()
}
fn remove_token_run(doc: &CstDocument, run: &[SyntaxToken]) -> Result<CstDocument, BlockedReason> {
let Some(first) = run.first().cloned() else {
return Ok(doc.clone());
};
let last = run[run.len() - 1].clone();
let edit = EditOperation::remove(
EditTarget::TokenSpan { first, last },
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
fn rename_key(
doc: &CstDocument,
parent: &ParentRef,
index: usize,
new_name: &str,
) -> Result<CstDocument, BlockedReason> {
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
let target = elements.get(index).ok_or(BlockedReason::TargetNotFound)?;
for (i, el) in elements.iter().enumerate() {
if i == index {
continue;
}
if entry_key_text(parent, el).as_deref() == Some(new_name) {
return Err(BlockedReason::RenameCollision);
}
}
let key_target = match parent {
ParentRef::Struct(_) => {
let field =
ast::StructField::cast(target.clone()).ok_or(BlockedReason::InvalidPayload)?;
let name = field.name().ok_or(BlockedReason::InvalidPayload)?;
EditTarget::TokenSpan {
first: name.clone(),
last: name,
}
}
ParentRef::Map(_) | ParentRef::EnumVariant(_) => {
let entry = ast::MapEntry::cast(target.clone()).ok_or(BlockedReason::InvalidPayload)?;
let key = entry.key().ok_or(BlockedReason::InvalidPayload)?;
EditTarget::Node(key.syntax().clone())
}
_ => return Err(BlockedReason::InvalidPayload),
};
let edit = EditOperation::replace(key_target, new_name.to_string(), TriviaPolicy::KEEP_ALL);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
fn map_entry_key_text(entry: &ast::MapEntry) -> Option<String> {
entry.key().map(|k| k.syntax().text())
}
fn entry_key_text(parent: &ParentRef, entry: &SyntaxNode) -> Option<String> {
match parent {
ParentRef::Struct(_) => ast::StructField::cast(entry.clone()).and_then(|f| f.name_text()),
ParentRef::Map(_) | ParentRef::EnumVariant(_) => ast::MapEntry::cast(entry.clone())
.and_then(|e| e.key())
.map(|k| k.syntax().text()),
_ => None,
}
}
fn set_value(
doc: &CstDocument,
parent: &ParentRef,
index: usize,
value: &str,
) -> Result<CstDocument, BlockedReason> {
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
let target = elements.get(index).ok_or(BlockedReason::TargetNotFound)?;
let value_node = match parent {
ParentRef::Struct(_) => ast::StructField::cast(target.clone())
.and_then(|f| f.value())
.map(|v| v.syntax().clone()),
ParentRef::Map(_) | ParentRef::EnumVariant(_) => ast::MapEntry::cast(target.clone())
.and_then(|e| e.value())
.map(|v| v.syntax().clone()),
ParentRef::List(_) | ParentRef::Tuple(_) => Some(target.clone()),
}
.ok_or(BlockedReason::InvalidPayload)?;
let edit = EditOperation::replace(
EditTarget::Node(value_node),
value.to_string(),
TriviaPolicy::KEEP_ALL,
);
apply_edit(doc, edit).map_err(|_| BlockedReason::TargetNotFound)
}
fn reorder_child(
doc: &CstDocument,
parent: &ParentRef,
from: usize,
to: usize,
) -> Result<CstDocument, BlockedReason> {
let located = locate_parent(doc, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements = child_nodes(parent, &located);
if from >= elements.len() || to >= elements.len() {
return Err(BlockedReason::TargetNotFound);
}
if from == to {
return Ok(doc.clone());
}
let moved_text = elements[from].text();
let after_remove = remove_child(doc, parent, from)?;
let located2 = locate_parent(&after_remove, parent).ok_or(BlockedReason::TargetNotFound)?;
let elements2 = child_nodes(parent, &located2);
let dest = to.min(elements2.len());
match parent {
ParentRef::List(_) | ParentRef::Tuple(_) => insert_child_text(
&after_remove,
&located2,
&elements2,
dest,
&moved_text,
false,
),
ParentRef::Struct(_) | ParentRef::Map(_) | ParentRef::EnumVariant(_) => {
let is_map = matches!(parent, ParentRef::Map(_));
insert_child_text(
&after_remove,
&located2,
&elements2,
dest,
&moved_text,
is_map,
)
}
}
}
fn swap_enum_variant(
doc: &CstDocument,
variant: &SyntaxNode,
new_name: &str,
new_fields: &[String],
placeholder: &str,
) -> Result<CstDocument, BlockedReason> {
let located = locate_node(doc, variant).ok_or(BlockedReason::TargetNotFound)?;
let variant_ast =
ast::EnumVariant::cast(located.clone()).ok_or(BlockedReason::InvalidPayload)?;
let old_names: Vec<String> = variant_ast
.entries()
.filter_map(|e| map_entry_key_text(&e))
.collect();
let name_tok = variant_ast.name().ok_or(BlockedReason::InvalidPayload)?;
let mut current = apply_edit(
doc,
EditOperation::replace(
EditTarget::TokenSpan {
first: name_tok.clone(),
last: name_tok,
},
new_name.to_string(),
TriviaPolicy::KEEP_ALL,
),
)
.map_err(|_| BlockedReason::TargetNotFound)?;
let parent_ref = ParentRef::EnumVariant(located.clone());
let remove_names: Vec<String> = old_names
.iter()
.filter(|n| !new_fields.contains(n))
.cloned()
.collect();
for name in remove_names.iter().rev() {
let located_now =
locate_parent(¤t, &parent_ref).ok_or(BlockedReason::TargetNotFound)?;
let entries = child_nodes(&parent_ref, &located_now);
if let Some(pos) = entries.iter().position(|e| {
ast::MapEntry::cast(e.clone())
.as_ref()
.and_then(map_entry_key_text)
.as_deref()
== Some(name)
}) {
current = remove_child(¤t, &parent_ref, pos)?;
}
}
for name in new_fields {
if old_names.contains(name) {
continue; }
let located_now =
locate_parent(¤t, &parent_ref).ok_or(BlockedReason::TargetNotFound)?;
let entries = child_nodes(&parent_ref, &located_now);
let at = entries.len();
current = insert_entry(¤t, &parent_ref, at, name, placeholder)?;
}
Ok(current)
}
fn add_field_across_rows(
doc: &CstDocument,
list: &SyntaxNode,
name: &str,
value: &str,
) -> Result<CstDocument, BlockedReason> {
let located = locate_node(doc, list).ok_or(BlockedReason::TargetNotFound)?;
if ast::List::cast(located.clone()).is_none() {
return Err(BlockedReason::InvalidPayload);
}
let row_count = ast::List::cast(located.clone())
.map(|l| {
l.items()
.filter(|v| matches!(v, ast::Value::Struct(_)))
.count()
})
.unwrap_or(0);
let mut current = doc.clone();
for row_idx in 0..row_count {
let located_now = locate_node(¤t, list).ok_or(BlockedReason::TargetNotFound)?;
let list_ast = ast::List::cast(located_now.clone()).ok_or(BlockedReason::InvalidPayload)?;
let struct_nodes: Vec<SyntaxNode> = list_ast
.items()
.filter_map(|v| match v {
ast::Value::Struct(s) => Some(s.syntax().clone()),
_ => None,
})
.collect();
let Some(struct_node) = struct_nodes.get(row_idx) else {
break;
};
let parent_ref = ParentRef::Struct(struct_node.clone());
let entries = child_nodes(&parent_ref, struct_node);
let at = entries.len();
current = insert_entry(¤t, &parent_ref, at, name, value)?;
}
Ok(current)
}