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,562 @@
//! Lexical containment writeback. Scalar fields use the shared SyntaxUpdate;
//! this phase only relocates declaration pieces and their owned comments.
use super::*;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Scope {
File(LogicalPath),
Element(Id),
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Key {
Live(Id),
Deleted(Id),
}
enum Piece {
Text(String),
/// A relative indentation adjustment, inherited by retained descendants.
Child(Key, isize),
}
struct Record {
path: LogicalPath,
syntax: language::EditDeclaration,
owned: Span,
indent: usize,
}
pub(super) fn update(
bundle: &mut SourceBundle,
current: &CompiledSourceProject,
project: &Project,
identities: &BTreeMap<Id, Id>,
original_order: &BTreeMap<LogicalPath, Vec<Id>>,
after: &BTreeMap<Id, String>,
limits: ProjectLimits,
) -> Result<(), Vec<SourceDiagnostic>> {
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
let structural = after.keys().any(|id| !origins.contains_key(id))
|| original_order
.values()
.flatten()
.any(|id| !identities.contains_key(id))
|| current.index.lexical_owners.iter().any(|(id, owner)| {
identities
.get(id)
.is_some_and(|new| project.elements[new].parent != identities.get(owner).copied())
});
if !structural {
return Ok(());
}
// Scalar changes may temporarily duplicate a deleted UUID or contradict an
// explicit parent until relocation. Parse syntax, then preserve original
// preorder provenance; intermediate IDs are never editing authority.
let mut records = BTreeMap::new();
let mut children: BTreeMap<(LogicalPath, Option<Id>), Vec<Id>> = BTreeMap::new();
let mut file_bodies = BTreeMap::new();
for (path, source) in &bundle.files {
let parsed = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| diagnostic(path, *error))?;
if parsed.len() != original_order[path].len() {
return Err(vec![problem(
bundle,
path,
Span::default(),
"stale_source_index",
"Scalar edits changed the declaration structure.",
)]);
}
let body = if path == &bundle.entry {
language::parse_unit(source, true, limits.model)
.map_err(|error| diagnostic(path, *error))?
.header
.expect("entry header")
.body
} else {
Span {
start: 0,
end: source.len(),
}
};
file_bodies.insert(path.clone(), body);
for (old, (_, syntax)) in original_order[path].iter().zip(parsed) {
children
.entry((path.clone(), current.index.lexical_owners.get(old).copied()))
.or_default()
.push(*old);
records.insert(
*old,
Record {
path: path.clone(),
owned: syntax.span,
indent: indent_at(source, syntax.span.start).len(),
syntax,
},
);
}
}
for ((path, owner), siblings) in &children {
let source = &bundle.files[path];
let body = owner.map_or(file_bodies[path], |id| records[&id].syntax.body);
let mut previous = body.start;
for (index, id) in siblings.iter().enumerate() {
let span = records[id].syntax.span;
let next = siblings
.get(index + 1)
.map_or(body.end, |next| records[next].syntax.span.start);
let owned = attached_span(source, span, previous, next);
records.get_mut(id).unwrap().owned = owned;
previous = owned.end;
}
}
let defaults = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut scopes = BTreeMap::new();
for (old, record) in &records {
if let Some(new) = identities.get(old) {
let scope = if current.index.lexical_owners.contains_key(old) {
project.elements[new]
.parent
.map(Scope::Element)
.unwrap_or_else(|| Scope::File(record.path.clone()))
} else {
Scope::File(record.path.clone())
};
scopes.insert(*new, scope);
}
}
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));
let mut chosen_defaults = BTreeMap::new();
let mut used: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
for id in &new_ids {
if let Some(parent) = project.elements.get(id).and_then(|element| element.parent) {
scopes.insert(*id, Scope::Element(parent));
continue;
}
let mut owner = semantic_owner(project, *id);
let mut inherited = None;
while let Some(id) = owner {
if id == project.id {
inherited = Some(bundle.entry.clone());
break;
}
if scopes.contains_key(&id) {
inherited = Some(file_for(id, &scopes));
break;
}
owner = semantic_owner(project, id);
}
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)
}
})
.clone()
});
scopes.insert(*id, Scope::File(path));
}
let mut incoming: BTreeMap<Scope, Vec<Id>> = BTreeMap::new();
for (id, scope) in &scopes {
let original_scope = origins
.get(id)
.and_then(|old| {
records.get(old).map(|record| {
if let Some(owner) = current.index.lexical_owners.get(old) {
identities.get(owner).copied().map(Scope::Element)
} else {
Some(Scope::File(record.path.clone()))
}
})
})
.flatten();
if original_scope.as_ref() != Some(scope) {
incoming.entry(scope.clone()).or_default().push(*id);
}
}
for ids in incoming.values_mut() {
ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
}
let mut generated = BTreeMap::new();
let mut base_indents: BTreeMap<_, _> = records
.iter()
.filter_map(|(old, record)| identities.get(old).map(|new| (*new, record.indent)))
.collect();
for id in &new_ids {
let mut text = unindent(&after[id]);
let path = file_for(*id, &scopes);
if bundle
.files
.get(&path)
.is_some_and(|source| newline(source) == "\r\n")
{
text = text.replace('\n', "\r\n");
}
let parsed =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
if matches!(scopes[id], Scope::Element(_))
&& let language::EditKind::Record(fields) = &parsed.fields.kind
&& let Some(parent) = fields.get("parent")
{
text.replace_range(parent.span.start..parent.span.end, "");
}
let syntax =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
generated.insert(*id, (text, syntax));
base_indents.insert(*id, 0);
}
let composition = Composition {
records: &records,
identities,
scopes: &scopes,
incoming: &incoming,
base_indents: &base_indents,
};
let mut templates = BTreeMap::new();
for (old, record) in &records {
let source = &bundle.files[&record.path];
let live = identities.get(old).copied();
let scope = live.map(Scope::Element);
let own = |text: &str| {
if live.is_some() {
text.to_owned()
} else {
trivia(text)
}
};
let mut pieces = vec![Piece::Text(own(
&source[record.owned.start..record.syntax.body.start]
))];
pieces.extend(
composition.body(
source,
record.syntax.body,
children
.get(&(record.path.clone(), Some(*old)))
.map_or(&[], Vec::as_slice),
scope.as_ref(),
record.indent + 4,
live.is_none(),
),
);
pieces.push(Piece::Text(own(
&source[record.syntax.body.end..record.owned.end]
)));
templates.insert(live.map_or(Key::Deleted(*old), Key::Live), pieces);
}
for (id, (text, syntax)) in &generated {
let mut pieces = vec![Piece::Text(text[..syntax.body.start].into())];
pieces.extend(composition.body(
text,
syntax.body,
&[],
Some(&Scope::Element(*id)),
4,
false,
));
pieces.push(Piece::Text(text[syntax.body.end..].into()));
templates.insert(Key::Live(*id), pieces);
}
let paths: BTreeSet<_> = bundle
.files
.keys()
.cloned()
.chain(scopes.values().filter_map(|scope| {
if let Scope::File(path) = scope {
Some(path.clone())
} else {
None
}
}))
.collect();
let mut output = BTreeMap::new();
let mut includes = String::new();
let mut bytes = 0usize;
for path in paths {
let source = bundle.files.get(&path).map_or("", String::as_str);
let body = file_bodies.get(&path).copied().unwrap_or_default();
let mut pieces = vec![Piece::Text(source[..body.start].into())];
pieces.extend(
composition.body(
source,
body,
children
.get(&(path.clone(), None))
.map_or(&[], Vec::as_slice),
Some(&Scope::File(path.clone())),
if path == bundle.entry { 4 } else { 0 },
false,
),
);
pieces.push(Piece::Text(source[body.end..].into()));
let text = render(
&pieces,
&templates,
limits.model.max_source_bytes.saturating_sub(bytes),
)
.ok_or_else(|| {
vec![problem(
bundle,
&path,
Span::default(),
"source_limit",
"Structural source update exceeds the source byte budget.",
)]
})?;
bytes = bytes.saturating_add(text.len());
if !bundle.files.contains_key(&path) {
includes.push_str(&format!(
" include {};{}",
quote(&path),
newline(&bundle.files[&bundle.entry])
));
}
output.insert(path, text);
}
if !includes.is_empty() {
let entry = output.get_mut(&bundle.entry).unwrap();
let body = language::parse_unit(entry, true, limits.model)
.map_err(|error| diagnostic(&bundle.entry, *error))?
.header
.unwrap()
.body;
entry.insert_str(body.end, &format!("{}{includes}", newline(entry)));
}
bundle.files = output;
Ok(())
}
fn diagnostic(path: &str, error: Diagnostic) -> Vec<SourceDiagnostic> {
vec![SourceDiagnostic {
path: path.into(),
diagnostic: error,
related: Vec::new(),
}]
}
fn file_for(mut id: Id, scopes: &BTreeMap<Id, Scope>) -> LogicalPath {
loop {
match &scopes[&id] {
Scope::File(path) => return path.clone(),
Scope::Element(owner) => id = *owner,
}
}
}
struct Composition<'a> {
records: &'a BTreeMap<Id, Record>,
identities: &'a BTreeMap<Id, Id>,
scopes: &'a BTreeMap<Id, Scope>,
incoming: &'a BTreeMap<Scope, Vec<Id>>,
base_indents: &'a BTreeMap<Id, usize>,
}
impl Composition<'_> {
fn body(
&self,
source: &str,
body: Span,
children: &[Id],
scope: Option<&Scope>,
child_indent: usize,
deleted: bool,
) -> Vec<Piece> {
let mut pieces = Vec::new();
let text = |s: &str| Piece::Text(if deleted { trivia(s) } else { s.into() });
let mut cursor = body.start;
for old in children {
let record = &self.records[old];
pieces.push(text(&source[cursor..record.owned.start]));
if let Some(new) = self.identities.get(old) {
if scope == self.scopes.get(new) {
pieces.push(Piece::Child(Key::Live(*new), 0));
} else {
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
} else {
// A deleted declaration's annotations remain in the nearest
// surviving original scope, separated from adjacent declarations.
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
pieces.push(Piece::Child(Key::Deleted(*old), 0));
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
cursor = record.owned.end;
}
let additions: &[Id] = scope
.and_then(|scope| self.incoming.get(scope))
.map_or(&[], Vec::as_slice);
if additions.is_empty() {
pieces.push(text(&source[cursor..body.end]));
return pieces;
}
let line = source[..body.end].rsplit('\n').next().unwrap_or("");
let at = if line.bytes().all(|b| b == b' ' || b == b'\t' || b == b'\r') {
body.end.saturating_sub(line.len()).max(cursor)
} else {
body.end
};
pieces.push(text(&source[cursor..at]));
pieces.push(Piece::Text(newline(source).into()));
for id in additions {
pieces.push(Piece::Child(
Key::Live(*id),
child_indent as isize - self.base_indents[id] as isize,
));
pieces.push(Piece::Text(newline(source).into()));
}
pieces.push(text(&source[at..body.end]));
pieces
}
}
/// Keep contiguous leading comments and same-line trailing comments with the
/// declaration. Blank-separated notes and field trailing comments stay in gaps.
fn attached_span(source: &str, span: Span, lower: usize, upper: usize) -> Span {
let gap = &source[lower..span.start];
let tokens = language::tokenize(gap);
let mut start = span.start;
for token in tokens.iter().rev() {
let token_start = lower + token.span.start;
let token_end = lower + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if source[token_start..token_end]
.bytes()
.filter(|b| *b == b'\n')
.count()
> 1
{
break;
}
}
language::TokenClass::Comment => {
let line = source[..token_start].rsplit('\n').next().unwrap_or("");
if !line.chars().all(char::is_whitespace) {
break;
}
start = token_start;
}
_ => break,
}
}
let line = source[..start].rsplit('\n').next().unwrap_or("");
if line.bytes().all(|b| b == b' ' || b == b'\t') {
start = start.saturating_sub(line.len()).max(lower);
}
let mut end = span.end;
let mut trailing = false;
for token in language::tokenize(&source[span.end..upper]) {
let token_start = span.end + token.span.start;
let token_end = span.end + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if let Some(newline) = source[token_start..token_end].find('\n') {
if trailing {
end = token_start + newline + 1;
}
break;
}
}
language::TokenClass::Comment => {
trailing = true;
end = token_end;
if source[token_start..token_end].contains('\n') {
break;
}
}
_ => break,
}
}
Span { start, end }
}
/// Expand a piece forest iteratively. Descendants are streamed directly to the
/// final file, avoiding recursive calls and repeated copying of whole subtrees.
fn render(pieces: &[Piece], templates: &BTreeMap<Key, Vec<Piece>>, limit: usize) -> Option<String> {
let mut output = String::new();
let mut stack = vec![(pieces.iter(), 0isize)];
let mut line_start = true;
// A lexical piece can end in the indentation immediately before another
// piece's closing brace. Delay it until that line's content is available.
let mut indentation = String::new();
while let Some((items, shift)) = stack.last_mut() {
match items.next() {
Some(Piece::Child(key, relative)) => {
let delta = *shift + relative;
stack.push((templates[key].iter(), delta));
}
Some(Piece::Text(text)) => {
for part in text.split_inclusive('\n') {
if line_start {
let leading = part.len() - part.trim_start_matches([' ', '\t']).len();
indentation.push_str(&part[..leading]);
let content = &part[leading..];
if output.len().saturating_add(indentation.len()) > limit {
return None;
}
if content.is_empty() {
continue;
}
let delta = if content.starts_with(['\r', '\n']) {
0
} else {
*shift
};
if delta > 0 {
if output
.len()
.saturating_add(indentation.len())
.saturating_add(delta as usize)
.saturating_add(content.len())
> limit
{
return None;
}
output.extend(std::iter::repeat_n(' ', delta as usize));
}
let trim = if delta < 0 {
indentation.len().min(delta.unsigned_abs())
} else {
0
};
output.push_str(&indentation[trim..]);
indentation.clear();
output.push_str(content);
} else {
output.push_str(part);
}
if output.len() > limit {
return None;
}
line_start = part.ends_with('\n');
}
}
None => {
stack.pop();
}
}
}
output.push_str(&indentation);
Some(output)
}