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
+88 -133
View File
@@ -4,6 +4,8 @@ use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_structure.rs"]
mod source_structure;
/// A portable slash-separated path, checked at every bundle boundary.
pub type LogicalPath = String;
@@ -88,6 +90,11 @@ pub struct IncludeLocation {
pub struct SourceIndex {
/// Includes the project header itself under the project UUID.
pub declarations: BTreeMap<Id, SourceLocation>,
/// Interior of each declaration body, excluding its opening/closing braces.
pub declaration_bodies: BTreeMap<Id, SourceLocation>,
/// Authored element nesting, distinct from an explicit semantic parent field.
/// Flat declarations have no entry; the project is not a lexical element owner.
pub lexical_owners: BTreeMap<Id, Id>,
/// Complete value spans for top-level declaration fields.
pub fields: BTreeMap<(Id, String), SourceLocation>,
pub includes: Vec<IncludeLocation>,
@@ -363,6 +370,8 @@ fn compile_once(
let mut alias_locations: BTreeMap<String, SourceLocation> = BTreeMap::new();
let mut project_header = None;
let mut declaration_locations = BTreeMap::new();
let mut declaration_bodies = BTreeMap::new();
let mut lexical_owners = BTreeMap::new();
let mut fields = BTreeMap::new();
let mut includes = Vec::new();
let mut insertions: Edits = BTreeMap::new();
@@ -461,6 +470,16 @@ fn compile_once(
aliases.insert(declaration.alias.clone(), declaration.id);
alias_locations.insert(declaration.alias.clone(), location.clone());
declaration_locations.insert(declaration.id, location);
declaration_bodies.insert(
declaration.id,
SourceLocation {
path: path.clone(),
span: declaration.body,
},
);
if let Some(owner) = declaration.lexical_owner {
lexical_owners.insert(declaration.id, owner);
}
for (key, span) in &declaration.field_spans {
fields.insert(
(declaration.id, key.clone()),
@@ -532,6 +551,8 @@ fn compile_once(
let header = project_header.expect("validated entry header");
let index = SourceIndex {
declarations: declaration_locations,
declaration_bodies,
lexical_owners,
fields,
includes,
project_header: SourceLocation {
@@ -847,63 +868,65 @@ pub fn update_bundle_with_identity_map(
.collect();
let mut bundle = current.bundle.clone();
let mut edits: Edits = BTreeMap::new();
for (id, old_text) in &before {
let location = &current.index.declarations[id];
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) = new_text {
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
&location.path,
location.span,
"source_edit",
error.message,
)]
})
};
let actual = parse(source)?;
let previous = parse(old_text)?;
let next = parse(new_text)?;
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
update.header(&actual.header, &previous.header, &next.header, &mut local);
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
&mut local,
);
} else {
erase_syntax(
source,
Span {
start: 0,
end: source.len(),
},
&mut local,
);
let mut original_order = BTreeMap::new();
for (path, source) in &bundle.files {
let declarations = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *error,
related: Vec::new(),
}]
})?;
original_order.insert(
path.clone(),
declarations.iter().map(|(id, _)| *id).collect(),
);
for (id, actual) in declarations {
let old_text = &before[&id];
// Deletion is structural: removing a parent's complete span here
// would overlap child edits and lose independently moved descendants.
let Some(new_text) = identities.get(&id).and_then(|id| after.get(id)) else {
continue;
};
if new_text == old_text {
continue;
}
edits
.entry(location.path.clone())
.or_default()
.extend(local.into_iter().map(|(span, text)| {
(
Span {
start: span.start + location.span.start,
end: span.end + location.span.start,
},
text,
)
}));
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
path,
actual.span,
"source_edit",
error.message,
)]
})
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
{
// The lexical structure is the authored definition of parent.
// Keep it implicit while the structural phase moves this record.
for fields in [&mut previous.fields, &mut next.fields] {
if let language::EditKind::Record(fields) = &mut fields.kind {
fields.remove("parent");
}
}
}
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -975,84 +998,16 @@ pub fn update_bundle_with_identity_map(
}
}
let defaults = default_ownership(project);
let mut assigned: BTreeMap<_, _> = current
.index
.declarations
.iter()
.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
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
let mut additions: BTreeMap<LogicalPath, String> = BTreeMap::new();
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(*id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
for id in new_ids {
let mut parent = semantic_owner(project, id);
let mut seen = BTreeSet::new();
let mut inherited = None;
while let Some(owner) = parent {
if !seen.insert(owner) {
break;
}
if let Some(path) = assigned.get(&owner) {
inherited = Some(path.clone());
break;
}
parent = semantic_owner(project, owner);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[&id].clone())
.or_insert_with(|| {
let preferred = &defaults[&id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used_paths)
}
})
.clone()
});
assigned.insert(id, path.clone());
additions
.entry(path)
.or_default()
.push_str(&unindent(&after[&id]));
}
let mut new_includes = String::new();
for (path, text) in additions {
if !bundle.files.contains_key(&path) {
bundle.files.insert(path.clone(), String::new());
new_includes.push_str(&format!(" include {};\n", quote(&path)));
}
let at = if path == bundle.entry {
current.index.project_body.span.end
} else {
bundle.files[&path].len()
};
edits
.entry(path)
.or_default()
.push((Span { start: at, end: at }, format!("\n{text}")));
}
if !new_includes.is_empty() {
let at = current.index.project_body.span.end;
edits
.entry(bundle.entry.clone())
.or_default()
.push((Span { start: at, end: at }, format!("\n{new_includes}")));
}
apply_edits(&mut bundle, edits)?;
source_structure::update(
&mut bundle,
current,
project,
identities,
&original_order,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
if &result.decoded.project != project {
return Err(vec![problem(