feat: preserve nested source projects across desktop and REST
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user