feat: preserve nested source projects across desktop and REST
This commit is contained in:
@@ -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(¤t.decoded.project, "owner"))
|
||||
.unwrap()
|
||||
.name = "Renamed owner".into();
|
||||
let result = apply(¤t, &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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t, &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(¤t.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(¤t.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(¤t, &project);
|
||||
assert_eq!(result.index.lexical_owners[&inner], root);
|
||||
assert_eq!(result.index.lexical_owners[&outer], inner);
|
||||
assert_eq!(comments(&result.bundle), comments(¤t.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 = ¤t.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(
|
||||
¤t,
|
||||
&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(¤t.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 = ¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &project);
|
||||
assert_eq!(result.index.declarations[&leaf].path, "b.arcadia");
|
||||
assert_eq!(result.index.lexical_owners[&leaf], destination);
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user