use std::fs;
use std::path::{Path, PathBuf};
use proptest::prelude::*;
use ronin_core::ast;
use ronin_core::transform::{
apply_structural, BlockedReason, ParentRef, StructuralOp, TransformOutcome,
};
use ronin_core::{parse, print, CstDocument, SyntaxKind, SyntaxNode};
fn applied(doc: &CstDocument, op: StructuralOp) -> String {
match apply_structural(doc, op) {
TransformOutcome::Applied(new_doc) => print(&new_doc),
TransformOutcome::Blocked(reason) => panic!("expected Applied, got Blocked({reason:?})"),
other => panic!("unexpected outcome variant: {other:?}"),
}
}
fn first_node(doc: &CstDocument, kind: SyntaxKind) -> SyntaxNode {
fn walk(n: &SyntaxNode, kind: SyntaxKind, out: &mut Option<SyntaxNode>) {
if out.is_some() {
return;
}
if n.kind() == kind {
*out = Some(n.clone());
return;
}
for c in n.children() {
walk(&c, kind, out);
}
}
let mut out = None;
walk(&doc.root(), kind, &mut out);
out.unwrap_or_else(|| panic!("no {kind:?} node in document"))
}
fn list_parent(doc: &CstDocument) -> ParentRef {
ParentRef::List(first_node(doc, SyntaxKind::List))
}
fn struct_parent(doc: &CstDocument) -> ParentRef {
ParentRef::Struct(first_node(doc, SyntaxKind::Struct))
}
fn map_parent(doc: &CstDocument) -> ParentRef {
ParentRef::Map(first_node(doc, SyntaxKind::Map))
}
fn struct_field_texts(doc: &CstDocument) -> Vec<String> {
let s = ast::Struct::cast(first_node(doc, SyntaxKind::Struct)).unwrap();
s.fields().map(|f| f.syntax().text()).collect()
}
fn list_item_texts(doc: &CstDocument) -> Vec<String> {
let l = ast::List::cast(first_node(doc, SyntaxKind::List)).unwrap();
l.items().map(|v| v.syntax().text()).collect()
}
fn reparse(text: &str) -> CstDocument {
parse(text)
}
#[test]
fn set_value_keeps_untouched_bytes() {
let src = "Foo(x: 1, y: 2) // keep\n";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::SetValue {
parent: struct_parent(&doc),
index: 0,
value: "99".into(),
},
);
assert_eq!(out, "Foo(x: 99, y: 2) // keep\n");
assert_eq!(print(&doc), src, "original untouched");
}
#[test]
fn insert_field_single_line_preserves_siblings() {
let src = "Foo(x: 1, y: 2)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::InsertField {
parent: struct_parent(&doc),
index: 2,
name: "z".into(),
value: "3".into(),
},
);
let new = reparse(&out);
let names: Vec<_> = ast::Struct::cast(first_node(&new, SyntaxKind::Struct))
.unwrap()
.fields()
.map(|f| f.name_text().unwrap_or_default())
.collect();
assert_eq!(names, vec!["x", "y", "z"]);
assert!(out.contains("x: 1"));
assert!(out.contains("y: 2"));
}
#[test]
fn insert_field_multiline_inherits_indent_and_trailing_comma() {
let src = "Foo(\n x: 1,\n y: 2,\n)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::InsertField {
parent: struct_parent(&doc),
index: 2,
name: "z".into(),
value: "3".into(),
},
);
assert_eq!(out, "Foo(\n x: 1,\n y: 2,\n z: 3,\n)");
}
#[test]
fn remove_middle_field_normalizes_separator() {
let src = "Foo(x: 1, y: 2, z: 3)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::RemoveField {
parent: struct_parent(&doc),
index: 1,
},
);
assert_eq!(out, "Foo(x: 1, z: 3)");
}
#[test]
fn remove_last_field_no_dangling_comma() {
let src = "Foo(x: 1, y: 2)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::RemoveField {
parent: struct_parent(&doc),
index: 1,
},
);
assert_eq!(out, "Foo(x: 1)");
}
#[test]
fn remove_element_preserves_adjacent_comment_on_sibling() {
let src = "[\n 1, // one\n 2, // two\n 3, // three\n]";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::RemoveElement {
parent: list_parent(&doc),
index: 1,
},
);
assert!(out.contains("// one"), "sibling 1 comment kept: {out:?}");
assert!(out.contains("// three"), "sibling 3 comment kept: {out:?}");
assert!(
!out.contains("// two"),
"removed element's comment gone: {out:?}"
);
let new = reparse(&out);
assert_eq!(list_item_texts(&new), vec!["1", "3"]);
}
#[test]
fn rename_field_in_place() {
let src = "Foo(alpha: 1, beta: 2)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::RenameKey {
parent: struct_parent(&doc),
index: 0,
new_name: "gamma".into(),
},
);
assert_eq!(out, "Foo(gamma: 1, beta: 2)");
}
#[test]
fn rename_collision_blocks_with_zero_change() {
let src = "Foo(a: 1, b: 2) // c\n";
let doc = parse(src);
let outcome = apply_structural(
&doc,
StructuralOp::RenameKey {
parent: struct_parent(&doc),
index: 0,
new_name: "b".into(), },
);
match outcome {
TransformOutcome::Blocked(BlockedReason::RenameCollision) => {}
other => panic!("expected RenameCollision, got {other:?}"),
}
assert_eq!(print(&doc), src);
}
#[test]
fn rename_map_key_collision_blocks() {
let src = "{ \"a\": 1, \"b\": 2 }";
let doc = parse(src);
let outcome = apply_structural(
&doc,
StructuralOp::RenameKey {
parent: map_parent(&doc),
index: 0,
new_name: "\"b\"".into(),
},
);
assert!(matches!(
outcome,
TransformOutcome::Blocked(BlockedReason::RenameCollision)
));
assert_eq!(print(&doc), src);
}
#[test]
fn reorder_field_moves_value_and_keeps_others() {
let src = "Foo(x: 1, y: 2, z: 3)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::ReorderChild {
parent: struct_parent(&doc),
from: 0,
to: 2,
},
);
let new = reparse(&out);
let names: Vec<_> = ast::Struct::cast(first_node(&new, SyntaxKind::Struct))
.unwrap()
.fields()
.map(|f| f.name_text().unwrap_or_default())
.collect();
assert_eq!(names, vec!["y", "z", "x"]);
}
#[test]
fn reorder_list_element() {
let src = "[1, 2, 3]";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::ReorderChild {
parent: list_parent(&doc),
from: 2,
to: 0,
},
);
let new = reparse(&out);
assert_eq!(list_item_texts(&new), vec!["3", "1", "2"]);
}
#[test]
fn insert_element_into_empty_list_uses_document_default() {
let src = "Foo(\n items: [],\n)";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::InsertElement {
parent: list_parent(&doc),
index: 0,
value: "1".into(),
},
);
let new = reparse(&out);
assert_eq!(list_item_texts(&new), vec!["1"]);
assert!(
out.contains("Foo(\n items: ["),
"wrapper preserved: {out:?}"
);
assert!(out.contains(",\n"), "trailing comma present: {out:?}");
}
#[test]
fn insert_element_into_empty_list_default_indent_when_undetectable() {
let src = "[]";
let doc = parse(src);
let out = applied(
&doc,
StructuralOp::InsertElement {
parent: list_parent(&doc),
index: 0,
value: "1".into(),
},
);
assert_eq!(out, "[\n 1,\n]");
}
#[test]
fn swap_enum_variant_keeps_shared_field() {
let src = "Shape { width: 10, height: 20 }";
let doc = parse(src);
let variant = first_node(&doc, SyntaxKind::EnumVariant);
let out = applied(
&doc,
StructuralOp::SwapEnumVariant {
variant,
new_name: "Circle".into(),
new_fields: vec!["width".into(), "radius".into()],
placeholder: "0".into(),
},
);
let new = reparse(&out);
let v = ast::EnumVariant::cast(first_node(&new, SyntaxKind::EnumVariant)).unwrap();
assert_eq!(v.name_text().as_deref(), Some("Circle"));
let fields: Vec<_> = v
.entries()
.map(|e| e.key().unwrap().syntax().text())
.collect();
assert_eq!(fields, vec!["width", "radius"]);
assert!(
out.contains("width: 10"),
"shared field value kept: {out:?}"
);
assert!(
out.contains("radius: 0"),
"new field placeholder added: {out:?}"
);
assert!(!out.contains("height"), "old-only field removed: {out:?}");
}
#[test]
fn add_field_across_rows_touches_every_struct() {
let src = "[A(x: 1), A(x: 2), A(x: 3)]";
let doc = parse(src);
let list = first_node(&doc, SyntaxKind::List);
let out = applied(
&doc,
StructuralOp::AddFieldAcrossRows {
list,
name: "y".into(),
value: "0".into(),
},
);
let new = reparse(&out);
let l = ast::List::cast(first_node(&new, SyntaxKind::List)).unwrap();
for item in l.items() {
let ast::Value::Struct(s) = item else {
panic!("expected struct row");
};
let names: Vec<_> = s.fields().map(|f| f.name_text().unwrap()).collect();
assert_eq!(names, vec!["x", "y"], "every row gained field y");
}
}
#[test]
fn target_not_found_blocks_with_zero_change() {
let src = "Foo(x: 1)";
let doc = parse(src);
let outcome = apply_structural(
&doc,
StructuralOp::RemoveField {
parent: struct_parent(&doc),
index: 99, },
);
assert!(matches!(
outcome,
TransformOutcome::Blocked(BlockedReason::TargetNotFound)
));
assert_eq!(print(&doc), src);
}
fn corpus_valid_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("corpus")
.join("valid")
}
#[test]
fn corpus_set_value_is_lossless_outside_target() {
let dir = corpus_valid_dir();
let mut checked = 0usize;
for entry in fs::read_dir(&dir).expect("read corpus/valid") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("ron") {
continue;
}
let bytes = fs::read(&path).expect("read fixture");
let Ok(src) = std::str::from_utf8(&bytes) else {
continue;
};
let doc = parse(src);
let Some(struct_node) = find_first(&doc.root(), SyntaxKind::Struct) else {
continue;
};
let s = ast::Struct::cast(struct_node.clone()).unwrap();
let Some((idx, field)) = s
.fields()
.enumerate()
.find(|(_, f)| matches!(f.value(), Some(ast::Value::Literal(_))))
else {
continue;
};
let old_value = field.value().unwrap().syntax().text();
let outcome = apply_structural(
&doc,
StructuralOp::SetValue {
parent: ParentRef::Struct(struct_node.clone()),
index: idx,
value: "424242".into(),
},
);
let TransformOutcome::Applied(new_doc) = outcome else {
panic!("set_value blocked on {path:?}");
};
let out = print(&new_doc);
let value_range = field.value().unwrap().syntax().text_range();
let mut expected = String::new();
expected.push_str(&src[..value_range.start()]);
expected.push_str("424242");
expected.push_str(&src[value_range.end()..]);
assert_eq!(
out, expected,
"lossless set-value on {path:?} (old={old_value})"
);
checked += 1;
}
assert!(checked > 0, "no struct-bearing corpus fixture exercised");
}
fn find_first(root: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxNode> {
if root.kind() == kind {
return Some(root.clone());
}
for child in root.children() {
if let Some(found) = find_first(&child, kind) {
return Some(found);
}
}
None
}
fn ron_doc() -> impl Strategy<Value = String> {
let leaf = prop_oneof![
(0i32..1000).prop_map(|n| n.to_string()),
any::<bool>().prop_map(|b| b.to_string()),
"[a-z]{1,5}".prop_map(|s| format!("\"{s}\"")),
];
leaf.prop_recursive(3, 24, 4, |inner| {
let ident = "[a-z][a-z0-9]{0,4}";
prop_oneof![
prop::collection::vec((ident, inner.clone()), 1..=4).prop_map(|fields| {
let body: String = fields
.iter()
.map(|(k, v)| format!(" {k}: {v}, // f\n"))
.collect();
format!("Foo(\n{body})")
}),
prop::collection::vec(inner.clone(), 1..=4).prop_map(|items| {
let body: String = items.iter().map(|v| format!(" {v},\n")).collect();
format!("[\n{body}]")
}),
]
})
}
proptest! {
#![proptest_config(ProptestConfig { cases: 200, ..ProptestConfig::default() })]
#[test]
fn prop_remove_field_keeps_surviving_siblings(src in ron_doc()) {
let doc = parse(&src);
prop_assume!(first_opt(&doc, SyntaxKind::Struct).is_some());
let before = struct_field_texts(&doc);
prop_assume!(before.len() >= 2);
let remove_idx = 0usize;
let outcome = apply_structural(
&doc,
StructuralOp::RemoveField {
parent: struct_parent(&doc),
index: remove_idx,
},
);
let TransformOutcome::Applied(new_doc) = outcome else {
prop_assert!(false, "remove blocked unexpectedly");
unreachable!();
};
let after = struct_field_texts(&new_doc);
let expected: Vec<String> = before
.iter()
.enumerate()
.filter(|(i, _)| *i != remove_idx)
.map(|(_, t)| t.clone())
.collect();
prop_assert_eq!(after, expected);
prop_assert_eq!(print(&doc), src);
}
#[test]
fn prop_set_value_is_byte_local(src in ron_doc()) {
let doc = parse(&src);
prop_assume!(first_opt(&doc, SyntaxKind::Struct).is_some());
let s = ast::Struct::cast(first_node(&doc, SyntaxKind::Struct)).unwrap();
let fields: Vec<_> = s.fields().collect();
prop_assume!(!fields.is_empty());
let target_value = fields[0].value();
prop_assume!(target_value.is_some());
let value_range = target_value.unwrap().syntax().text_range();
let outcome = apply_structural(
&doc,
StructuralOp::SetValue {
parent: struct_parent(&doc),
index: 0,
value: "777".into(),
},
);
let TransformOutcome::Applied(new_doc) = outcome else {
prop_assert!(false, "set-value blocked unexpectedly");
unreachable!();
};
let out = print(&new_doc);
let mut expected = String::new();
expected.push_str(&src[..value_range.start()]);
expected.push_str("777");
expected.push_str(&src[value_range.end()..]);
prop_assert_eq!(out, expected);
}
#[test]
fn prop_insert_field_keeps_existing_siblings(src in ron_doc()) {
let doc = parse(&src);
prop_assume!(first_opt(&doc, SyntaxKind::Struct).is_some());
let before = struct_field_texts(&doc);
prop_assume!(!before.is_empty());
let outcome = apply_structural(
&doc,
StructuralOp::InsertField {
parent: struct_parent(&doc),
index: before.len(),
name: "newf".into(),
value: "0".into(),
},
);
let TransformOutcome::Applied(new_doc) = outcome else {
prop_assert!(false, "insert blocked unexpectedly");
unreachable!();
};
let new = reparse(&print(&new_doc));
let after = struct_field_texts(&new);
prop_assert!(after.len() == before.len() + 1);
for (b, a) in before.iter().zip(after.iter()) {
prop_assert_eq!(b, a);
}
let new_name = ast::Struct::cast(first_node(&new, SyntaxKind::Struct))
.unwrap()
.fields()
.last()
.and_then(|f| f.name_text());
prop_assert_eq!(new_name.as_deref(), Some("newf"));
}
#[test]
fn prop_reorder_list_permutes_elements(src in ron_doc()) {
let doc = parse(&src);
prop_assume!(first_opt(&doc, SyntaxKind::List).is_some());
let before = list_item_texts(&doc);
prop_assume!(before.len() >= 2);
let outcome = apply_structural(
&doc,
StructuralOp::ReorderChild {
parent: list_parent(&doc),
from: 0,
to: before.len() - 1,
},
);
let TransformOutcome::Applied(new_doc) = outcome else {
prop_assert!(false, "reorder blocked unexpectedly");
unreachable!();
};
let new = reparse(&print(&new_doc));
let after = list_item_texts(&new);
let mut bsorted = before.clone();
bsorted.sort();
let mut asorted = after.clone();
asorted.sort();
prop_assert_eq!(bsorted, asorted);
prop_assert_eq!(after.last().cloned(), before.first().cloned());
prop_assert_eq!(print(&doc), src);
}
#[test]
fn prop_blocked_rename_is_zero_change(src in ron_doc()) {
let doc = parse(&src);
prop_assume!(first_opt(&doc, SyntaxKind::Struct).is_some());
let s = ast::Struct::cast(first_node(&doc, SyntaxKind::Struct)).unwrap();
let names: Vec<_> = s.fields().filter_map(|f| f.name_text()).collect();
prop_assume!(names.len() >= 2);
let outcome = apply_structural(
&doc,
StructuralOp::RenameKey {
parent: struct_parent(&doc),
index: 0,
new_name: names[1].clone(),
},
);
prop_assert!(matches!(
outcome,
TransformOutcome::Blocked(BlockedReason::RenameCollision)
));
prop_assert_eq!(print(&doc), src);
}
}
fn first_opt(doc: &CstDocument, kind: SyntaxKind) -> Option<SyntaxNode> {
find_first(&doc.root(), kind)
}