feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
@@ -0,0 +1,388 @@
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]),
}, ProjectLimits::default()).unwrap()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn apply(current: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(current, project, ProjectLimits::default()).unwrap();
assert_eq!(&result.decoded.project, project);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle,
"unchanged writeback is byte-stable"
);
result
}
fn comments(bundle: &SourceBundle) -> Vec<String> {
let mut result: Vec<_> = bundle
.files
.values()
.flat_map(|source| {
language::tokenize(source)
.into_iter()
.filter(|t| t.class == language::TokenClass::Comment)
.map(|t| source[t.span.start..t.span.end].to_owned())
})
.collect();
result.sort();
result
}
#[test]
fn nested_scalar_edits_touch_only_authored_tokens() {
let current = compile(
"// owner rationale\nsa_function owner \"Owner\" {\n description \"owner notes\";\n sa_function child \"Child\" { description \"before\" ; // inline\n } // child tail\n}\n",
"// unrelated file\n",
);
let mut project = current.decoded.project.clone();
let child = id(&project, "child");
project.elements.get_mut(&child).unwrap().description = "after".into();
project
.elements
.get_mut(&id(&current.decoded.project, "owner"))
.unwrap()
.name = "Renamed owner".into();
let result = apply(&current, &project);
assert_eq!(
result.bundle.files["a.arcadia"],
current.bundle.files["a.arcadia"]
.replace("\"Owner\"", "\"Renamed owner\"")
.replace("\"before\"", "\"after\"")
);
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
#[test]
fn cross_file_move_carries_attached_comments_and_subtree_but_leaves_gap_notes() {
let current = compile(
"sa_function left \"Left\" {\n // stationary gap note\n\n // child rationale\n sa_function child \"Child\" {\n sa_function grandchild \"Grandchild\" {}\n } // child trailing rationale\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right, grandchild) = (
id(&project, "child"),
id(&project, "right"),
id(&project, "grandchild"),
);
project.elements.get_mut(&child).unwrap().parent = Some(right);
project.elements.get_mut(&grandchild).unwrap().name = "Updated grandchild".into();
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&child].path, "b.arcadia");
assert_eq!(result.index.declarations[&grandchild].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&child], right);
assert_eq!(result.index.lexical_owners[&grandchild], child);
assert!(result.bundle.files["a.arcadia"].contains("// stationary gap note"));
assert!(!result.bundle.files["b.arcadia"].contains("// stationary gap note"));
assert!(result.bundle.files["b.arcadia"].contains("// child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// child trailing rationale"));
assert!(
result.bundle.files["b.arcadia"].contains("\n } // child trailing rationale"),
"moved closing brace must align with its header: {}",
result.bundle.files["b.arcadia"]
);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn moving_a_child_keeps_the_owners_opening_line_comment_with_its_owner() {
let current = compile(
"sa_function left \"Left\" { // Owner rationale belongs to Left.\n // Child rationale follows Child.\n sa_function child \"Child\" {}\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let result = apply(&current, &project);
assert!(result.bundle.files["a.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(!result.bundle.files["b.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(result.bundle.files["b.arcadia"].contains("// Child rationale follows Child."));
assert!(!result.bundle.files["a.arcadia"].contains("// Child rationale follows Child."));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn compact_same_file_reparent_and_unnest_preserve_explicit_parent_comments() {
let current = compile(
"sa_function left \"Left\" {sa_function child \"Child\" {parent // parent rationale\n @left;}} sa_function right \"Right\" {}\n",
"// untouched\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let moved = apply(&current, &project);
assert_eq!(moved.index.lexical_owners[&child], right);
assert!(moved.bundle.files["a.arcadia"].contains("// parent rationale"));
assert!(moved.bundle.files["a.arcadia"].contains("@right;"));
project.elements.get_mut(&child).unwrap().parent = None;
let unnested = apply(&moved, &project);
assert!(!unnested.index.lexical_owners.contains_key(&child));
assert_eq!(unnested.index.declarations[&child].path, "a.arcadia");
assert_eq!(comments(&unnested.bundle), comments(&current.bundle));
}
#[test]
fn additions_nest_in_existing_and_new_owners_while_authored_flat_records_stay_flat() {
let current = compile(
"sa_function owner \"Owner\" {}\nsa_function flat \"Flat\" {parent @owner;}\n",
"// unrelated\n",
);
let mut project = current.decoded.project.clone();
let (owner, flat) = (id(&project, "owner"), id(&project, "flat"));
let added = project
.add_element(Kind::Function, Layer::Sa, "Added", Some(owner))
.unwrap();
let grandchild = project
.add_element(Kind::Function, Layer::Sa, "Added descendant", Some(added))
.unwrap();
project.elements.get_mut(&flat).unwrap().parent = Some(added);
let fresh_root = project
.add_element(Kind::Function, Layer::Sa, "Fresh root", None)
.unwrap();
let fresh_child = project
.add_element(Kind::Function, Layer::Sa, "Fresh child", Some(fresh_root))
.unwrap();
let result = apply(&current, &project);
for (child, parent) in [
(added, owner),
(grandchild, added),
(fresh_child, fresh_root),
] {
assert_eq!(result.index.lexical_owners[&child], parent);
assert_eq!(
result.index.declarations[&child].path,
result.index.declarations[&parent].path
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
assert!(!result.index.lexical_owners.contains_key(&flat));
assert_eq!(result.index.declarations[&flat].path, "a.arcadia");
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
}
#[test]
fn deleting_owner_retains_annotations_and_extracts_independently_moved_descendant() {
let current = compile(
"// deleted owner rationale\nsa_function doomed \"Doomed\" {\n description \"old\"; // deleted field rationale\n // retained child rationale\n sa_function rescued \"Rescued\" {} // retained child tail\n // deleted child rationale\n sa_function removed \"Removed\" {}\n}\nsa_function neighbor \"Neighbor\" {}\n",
"sa_function destination \"Destination\" {}\n",
);
let mut project = current.decoded.project.clone();
let (doomed, removed, rescued, destination) = (
id(&project, "doomed"),
id(&project, "removed"),
id(&project, "rescued"),
id(&project, "destination"),
);
project.elements.remove(&doomed);
project.elements.remove(&removed);
project.elements.get_mut(&rescued).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&rescued], destination);
for comment in [
"deleted owner rationale",
"deleted field rationale",
"deleted child rationale",
] {
assert!(result.bundle.files["a.arcadia"].contains(comment));
assert!(!result.bundle.files["b.arcadia"].contains(comment));
}
assert!(result.bundle.files["b.arcadia"].contains("retained child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("retained child tail"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
// Old annotations are separated: moving the neighbor in a later command
// cannot accidentally steal comments of deleted declarations.
let neighbor = id(&project, "neighbor");
project.elements.get_mut(&neighbor).unwrap().description = "changed".into();
let next = apply(&result, &project);
assert_eq!(comments(&next.bundle), comments(&current.bundle));
}
#[test]
fn independent_descendant_move_can_reverse_old_containment_without_overlapping_edits() {
let current = compile(
"sa_function root \"Root\" {\n // outer rationale\n sa_function outer \"Outer\" {\n // inner rationale\n sa_function inner \"Inner\" {}\n }\n}\n",
"",
);
let mut project = current.decoded.project.clone();
let (root, outer, inner) = (
id(&project, "root"),
id(&project, "outer"),
id(&project, "inner"),
);
project.elements.get_mut(&outer).unwrap().parent = Some(inner);
project.elements.get_mut(&inner).unwrap().parent = Some(root);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&inner], root);
assert_eq!(result.index.lexical_owners[&outer], inner);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_uuid_alias_swap_and_cross_file_move_follow_command_provenance() {
let current = compile(
"sa_function left \"Left\" {\n // first rationale\n sa_function first \"First\" {}\n}\n",
"sa_function right \"Right\" {\n // second rationale\n sa_function second \"Second\" {}\n}\n",
);
let old = &current.decoded.project;
let (first, second, right) = (id(old, "first"), id(old, "second"), id(old, "right"));
let temporary = Id::new_v4();
let prepared = prepare_command(
old,
&Command::Batch(vec![
Command::Reidentify(first, temporary),
Command::Reidentify(second, first),
Command::Reidentify(temporary, second),
Command::Reparent {
element: second,
parent: Some(right),
},
]),
)
.unwrap();
let result = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.decoded.project, prepared.project);
assert_eq!(result.index.declarations[&second].path, "b.arcadia");
assert!(result.bundle.files["b.arcadia"].contains("// first rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// second rationale"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deleted_owner_identity_reused_by_new_owner_does_not_keep_old_lexical_ownership() {
let current = compile(
"// retired owner note\nsa_function old_owner \"Old owner\" {\n // rescued child note\n sa_function child \"Child\" {}\n}\n",
"",
);
let old = &current.decoded.project;
let (owner, child) = (id(old, "old_owner"), id(old, "child"));
let mut project = old.clone();
project.elements.remove(&owner);
let mut replacement = project
.make_element(Kind::Function, Layer::Sa, "New owner", None)
.unwrap();
replacement.id = owner;
project.elements.insert(owner, replacement);
let mut identities: BTreeMap<_, _> = old
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.remove(&owner);
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
assert_eq!(result.decoded.project, project);
let destination = &result.index.declarations[&owner].path;
assert_ne!(destination, "a.arcadia");
assert_eq!(&result.index.declarations[&child].path, destination);
assert_eq!(result.index.lexical_owners[&child], owner);
assert!(result.bundle.files["a.arcadia"].contains("retired owner note"));
assert!(!result.bundle.files[destination].contains("retired owner note"));
assert!(result.bundle.files[destination].contains("rescued child note"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_typed_reference_list_updates_keep_item_rationale_while_port_moves() {
let current = compile(
"sa_exchange_item first \"First\" {}\nsa_exchange_item second \"Second\" {}\nsa_function sender \"Sender\" {\n // port rationale\n sa_port output \"Output\" { contract { FunctionOutput { outgoing_items [\n @first, // first item rationale\n @second // second item rationale\n ]; }; }; }\n}\n",
"sa_function other \"Other\" {}\n",
);
let mut project = current.decoded.project.clone();
let (first, output, other) = (
id(&project, "first"),
id(&project, "output"),
id(&project, "other"),
);
project.elements.get_mut(&first).unwrap().alias = "renamed_first".into();
let port = project.elements.get_mut(&output).unwrap();
port.parent = Some(other);
let Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) = port.shared_mut()
else {
panic!("output contract")
};
outgoing_items.reverse();
let result = apply(&current, &project);
let text = &result.bundle.files["b.arcadia"];
assert!(text.find("@second").unwrap() < text.find("@renamed_first").unwrap());
assert_eq!(result.index.lexical_owners[&output], other);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn crlf_moved_and_new_children_preserve_authored_and_destination_line_endings() {
let current = compile(
"sa_function left \"Left\" {\r\n // child rationale\r\n sa_function child \"Child\" {} // trailing\r\n}\r\n",
"sa_function right \"Right\" {}\r\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
project
.add_element(Kind::Function, Layer::Sa, "Added", Some(right))
.unwrap();
let result = apply(&current, &project);
for path in ["a.arcadia", "b.arcadia"] {
let text = &result.bundle.files[path];
assert!(
!text.replace("\r\n", "").contains('\n'),
"bare LF introduced in {path}: {text:?}"
);
}
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deep_structural_writeback_runs_on_a_small_thread_stack() {
std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(|| {
let mut source = String::new();
for level in 0..600 {
source.push_str(&format!("sa_function n{level} \"N{level}\" {{"));
}
source.push_str(&"}".repeat(600));
let current = compile(&source, "sa_function destination \"Destination\" {}\n");
let mut project = current.decoded.project.clone();
let (leaf, destination) = (id(&project, "n599"), id(&project, "destination"));
project.elements.get_mut(&leaf).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&leaf].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&leaf], destination);
})
.unwrap()
.join()
.unwrap();
}