Preserve source identity ownership across prepared model commands
This commit is contained in:
+101
-13
@@ -1,6 +1,6 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
|
||||
@@ -93,6 +93,65 @@ pub struct Session {
|
||||
pub struct PreparedCommand {
|
||||
pub project: Project,
|
||||
pub selected: Option<Id>,
|
||||
/// Complete correspondence from surviving original identities to candidate
|
||||
/// identities, including unchanged objects. Newly created objects have no
|
||||
/// origin. Replacement establishes correspondence by common original UUIDs.
|
||||
pub identity_map: BTreeMap<Id, Id>,
|
||||
}
|
||||
|
||||
/// Track actual identity operations, not aliases or structural similarity.
|
||||
/// The reverse map composes renames without scanning every original object.
|
||||
struct IdentityProvenance {
|
||||
original: BTreeSet<Id>,
|
||||
current_to_original: BTreeMap<Id, Id>,
|
||||
}
|
||||
|
||||
impl IdentityProvenance {
|
||||
fn new(project: &Project) -> Self {
|
||||
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
let current_to_original = original.iter().map(|id| (*id, *id)).collect();
|
||||
Self {
|
||||
original,
|
||||
current_to_original,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, project: &Project) {
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original = self
|
||||
.original
|
||||
.intersection(&present)
|
||||
.map(|id| (*id, *id))
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn retain_present(&mut self, project: &Project) {
|
||||
if !self.current_to_original.is_empty() {
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original
|
||||
.retain(|id, _| present.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
fn into_map(self) -> BTreeMap<Id, Id> {
|
||||
self.current_to_original
|
||||
.into_iter()
|
||||
.map(|(current, original)| (original, current))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn reidentify_tracked(
|
||||
project: &mut Project,
|
||||
old: Id,
|
||||
new: Id,
|
||||
provenance: &mut IdentityProvenance,
|
||||
) -> Result<(), ModelError> {
|
||||
reidentify(project, old, new)?;
|
||||
if let Some(original) = provenance.current_to_original.remove(&old) {
|
||||
provenance.current_to_original.insert(new, original);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prepare_command(
|
||||
@@ -101,12 +160,21 @@ pub fn prepare_command(
|
||||
) -> Result<PreparedCommand, ModelError> {
|
||||
let mut candidate = project.clone();
|
||||
let mut pending_deletions = BTreeSet::new();
|
||||
let selected = apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
|
||||
let mut provenance = IdentityProvenance::new(project);
|
||||
let selected = apply_command(
|
||||
&mut candidate,
|
||||
command,
|
||||
0,
|
||||
&mut pending_deletions,
|
||||
&mut provenance,
|
||||
)?;
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
provenance.retain_present(&candidate);
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(PreparedCommand {
|
||||
project: candidate,
|
||||
selected,
|
||||
identity_map: provenance.into_map(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +239,7 @@ impl Session {
|
||||
let PreparedCommand {
|
||||
project: candidate,
|
||||
selected,
|
||||
..
|
||||
} = prepare_command(&self.project, &command)?;
|
||||
if candidate != self.project {
|
||||
self.past
|
||||
@@ -197,8 +266,15 @@ pub fn validate_candidate(p: &Project) -> Result<(), ModelError> {
|
||||
pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project, ModelError> {
|
||||
let mut candidate = project.clone();
|
||||
let mut pending_deletions = BTreeSet::new();
|
||||
let mut provenance = IdentityProvenance::new(project);
|
||||
for command in commands {
|
||||
apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
|
||||
apply_command(
|
||||
&mut candidate,
|
||||
command,
|
||||
0,
|
||||
&mut pending_deletions,
|
||||
&mut provenance,
|
||||
)?;
|
||||
}
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
validate_candidate(&candidate)?;
|
||||
@@ -266,6 +342,7 @@ fn apply_command(
|
||||
c: &Command,
|
||||
depth: usize,
|
||||
pending_deletions: &mut BTreeSet<Id>,
|
||||
provenance: &mut IdentityProvenance,
|
||||
) -> Result<Option<Id>, ModelError> {
|
||||
if depth > 64 {
|
||||
return Err(ModelError(
|
||||
@@ -276,12 +353,13 @@ fn apply_command(
|
||||
Command::Replace(value) => {
|
||||
*p = value.clone();
|
||||
pending_deletions.clear();
|
||||
provenance.replace(p);
|
||||
None
|
||||
}
|
||||
Command::Batch(commands) => {
|
||||
let mut selected = None;
|
||||
for command in commands {
|
||||
let id = apply_command(p, command, depth + 1, pending_deletions)?;
|
||||
let id = apply_command(p, command, depth + 1, pending_deletions, provenance)?;
|
||||
if id.is_some() {
|
||||
selected = id;
|
||||
}
|
||||
@@ -342,7 +420,7 @@ fn apply_command(
|
||||
let mut e = value.clone();
|
||||
e.id = *old;
|
||||
p.elements.insert(*old, e);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
if ownership_change {
|
||||
attach(p, value.id);
|
||||
}
|
||||
@@ -360,7 +438,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.relations.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
if pending_deletions.remove(old) {
|
||||
pending_deletions.insert(value.id);
|
||||
}
|
||||
@@ -378,7 +456,9 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.diagrams.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
// Replacing a diagram can remove embedded placement identities.
|
||||
provenance.retain_present(p);
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
@@ -393,7 +473,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.artifact_links.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateTrace(value) => {
|
||||
@@ -408,7 +488,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.traces.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateNode { diagram, node } => {
|
||||
@@ -432,7 +512,7 @@ fn apply_command(
|
||||
let mut n = node.clone();
|
||||
n.id = *original;
|
||||
d.nodes.insert(*original, n);
|
||||
reidentify(p, *original, node.id)?;
|
||||
reidentify_tracked(p, *original, node.id, provenance)?;
|
||||
Some(node.id)
|
||||
}
|
||||
Command::RelocateNode { node, diagram } => {
|
||||
@@ -467,7 +547,7 @@ fn apply_command(
|
||||
name,
|
||||
description,
|
||||
} => {
|
||||
reidentify(p, p.id, *id)?;
|
||||
reidentify_tracked(p, p.id, *id, provenance)?;
|
||||
p.alias = alias.clone();
|
||||
p.name = name.clone();
|
||||
p.description = description.clone();
|
||||
@@ -483,7 +563,7 @@ fn apply_command(
|
||||
Some(*element)
|
||||
}
|
||||
Command::Reidentify(old, new) => {
|
||||
reidentify(p, *old, *new)?;
|
||||
reidentify_tracked(p, *old, *new, provenance)?;
|
||||
if pending_deletions.remove(old) {
|
||||
pending_deletions.insert(*new);
|
||||
}
|
||||
@@ -518,7 +598,13 @@ fn apply_command(
|
||||
items: vec![],
|
||||
},
|
||||
)?;
|
||||
return apply_command(p, &Command::Batch(commands), depth + 1, pending_deletions);
|
||||
return apply_command(
|
||||
p,
|
||||
&Command::Batch(commands),
|
||||
depth + 1,
|
||||
pending_deletions,
|
||||
provenance,
|
||||
);
|
||||
}
|
||||
Some(p.relate(*kind, name, *source, *target))
|
||||
}
|
||||
@@ -530,6 +616,7 @@ fn apply_command(
|
||||
return Err(missing());
|
||||
}
|
||||
crate::references::delete_cascade_deferred(p, *id, pending_deletions);
|
||||
provenance.retain_present(p);
|
||||
None
|
||||
}
|
||||
Command::AddStep(owner, exchange) => {
|
||||
@@ -569,6 +656,7 @@ fn apply_command(
|
||||
.and_then(|s| s.get(*index))
|
||||
.ok_or_else(|| ModelError("Step index is out of range.".into()))?;
|
||||
crate::references::delete_cascade_deferred(p, id, pending_deletions);
|
||||
provenance.retain_present(p);
|
||||
Some(*owner)
|
||||
}
|
||||
Command::MoveStep(owner, from, to) => {
|
||||
|
||||
@@ -261,6 +261,16 @@ pub fn inspect_includes(
|
||||
|
||||
type Edits = BTreeMap<LogicalPath, Vec<(Span, String)>>;
|
||||
|
||||
/// Diagnose the exact editable bytes, without inserting omitted UUIDs. Locations
|
||||
/// refer to the supplied bundle, including when successful compilation would
|
||||
/// otherwise stabilize identities before returning its index and diagnostics.
|
||||
pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDiagnostic> {
|
||||
match compile_once(bundle, limits) {
|
||||
Ok((compiled, _)) => compiled.diagnostics,
|
||||
Err(diagnostics) => diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
@@ -727,6 +737,28 @@ pub fn update_bundle(
|
||||
current: &CompiledSourceProject,
|
||||
project: &Project,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let identities = current
|
||||
.decoded
|
||||
.project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.filter(|(id, _)| project.contains(*id))
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
update_bundle_with_identity_map(current, project, &identities, limits)
|
||||
}
|
||||
|
||||
/// Preserve declaration and reference-comment ownership through identity edits.
|
||||
/// `identities` maps every surviving original identity to its candidate identity;
|
||||
/// new records have no entry. Use the map from `prepare_command`, including for
|
||||
/// nested batches and identity swaps. An arbitrary model replacement without
|
||||
/// command provenance can use `update_bundle`'s ordinary same-UUID matching.
|
||||
pub fn update_bundle_with_identity_map(
|
||||
current: &CompiledSourceProject,
|
||||
project: &Project,
|
||||
identities: &BTreeMap<Id, Id>,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
// Public document/index fields are convenient for callers, but cannot become
|
||||
// an unchecked editing authority if a caller has changed either of them.
|
||||
@@ -760,26 +792,68 @@ pub fn update_bundle(
|
||||
)]
|
||||
})?;
|
||||
let old = ¤t.decoded.project;
|
||||
// A supplied map is editing intent, never permission to create duplicate
|
||||
// records, refer to absent objects, or move syntax between record classes.
|
||||
let category = |model: &Project, id: Id| {
|
||||
if model.id == id {
|
||||
0
|
||||
} else if model.elements.contains_key(&id) {
|
||||
1
|
||||
} else if model.relations.contains_key(&id) {
|
||||
2
|
||||
} else if model.diagrams.contains_key(&id) {
|
||||
3
|
||||
} else if model.artifact_links.contains_key(&id) {
|
||||
4
|
||||
} else if model.traces.contains_key(&id) {
|
||||
5
|
||||
} else {
|
||||
6 // Node placements; existence is checked separately.
|
||||
}
|
||||
};
|
||||
if identities.values().collect::<BTreeSet<_>>().len() != identities.len()
|
||||
|| identities.iter().any(|(before, after)| {
|
||||
!old.contains(*before)
|
||||
|| !project.contains(*after)
|
||||
|| category(old, *before) != category(project, *after)
|
||||
})
|
||||
{
|
||||
return Err(vec![problem(
|
||||
¤t.bundle,
|
||||
¤t.bundle.entry,
|
||||
Span::default(),
|
||||
"invalid_identity_map",
|
||||
"Source identity correspondence must map existing records one-to-one within their record class.",
|
||||
)]);
|
||||
}
|
||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let old_aliases: BTreeMap<_, _> = old
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id))
|
||||
.map(|(id, alias)| (alias.to_owned(), format!("original:{id}")))
|
||||
.collect();
|
||||
let new_aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id))
|
||||
.map(|(id, alias)| {
|
||||
let identity = origins.get(&id).map_or_else(
|
||||
|| format!("new:{id}"),
|
||||
|origin| format!("original:{origin}"),
|
||||
);
|
||||
(alias.to_owned(), identity)
|
||||
})
|
||||
.collect();
|
||||
let mut bundle = current.bundle.clone();
|
||||
let mut edits: Edits = BTreeMap::new();
|
||||
for (id, old_text) in &before {
|
||||
let location = ¤t.index.declarations[id];
|
||||
if after.get(id) != Some(old_text) {
|
||||
let new_text = identities.get(id).and_then(|id| after.get(id));
|
||||
if new_text != Some(old_text) {
|
||||
let source = &bundle.files[&location.path][location.span.start..location.span.end];
|
||||
let mut local = Vec::new();
|
||||
if let Some(new_text) = after.get(id) {
|
||||
if let Some(new_text) = new_text {
|
||||
let parse = |text| {
|
||||
language::edit_declaration(text).map_err(|error| {
|
||||
vec![problem(
|
||||
@@ -906,8 +980,7 @@ pub fn update_bundle(
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|(id, _)| project.contains(**id))
|
||||
.map(|(id, location)| (*id, location.path.clone()))
|
||||
.filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone())))
|
||||
.collect();
|
||||
let mut chosen_defaults = BTreeMap::new();
|
||||
let mut used_paths: BTreeSet<_> = bundle
|
||||
@@ -919,7 +992,7 @@ pub fn update_bundle(
|
||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||
let mut new_ids: Vec<_> = after
|
||||
.keys()
|
||||
.filter(|id| !before.contains_key(*id))
|
||||
.filter(|id| !origins.contains_key(*id))
|
||||
.copied()
|
||||
.collect();
|
||||
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
|
||||
@@ -1089,8 +1162,8 @@ struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, Id>,
|
||||
new_aliases: &'a BTreeMap<String, Id>,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1420,7 +1493,7 @@ impl SyntaxUpdate<'_> {
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
match &value.kind {
|
||||
|
||||
Reference in New Issue
Block a user