Preserve source comments and layout through model token edits
This commit is contained in:
@@ -933,6 +933,143 @@ pub(crate) fn parse_unit(
|
||||
.unit(entry, true)
|
||||
.map_err(|e| Box::new(diagnostic(source, e)))
|
||||
}
|
||||
|
||||
// Editing positions are derived from the existing, validated parser AST. They
|
||||
// do not define another grammar or change the shared V2 migration AST.
|
||||
pub(crate) struct EditField {
|
||||
pub(crate) span: Span,
|
||||
pub(crate) value: EditValue,
|
||||
}
|
||||
pub(crate) struct EditValue {
|
||||
pub(crate) span: Span,
|
||||
pub(crate) kind: EditKind,
|
||||
}
|
||||
pub(crate) enum EditKind {
|
||||
Atom,
|
||||
Reference(String),
|
||||
Record(BTreeMap<String, EditField>),
|
||||
List(Vec<EditValue>),
|
||||
}
|
||||
pub(crate) struct EditDeclaration {
|
||||
pub(crate) header: BTreeMap<&'static str, Span>,
|
||||
pub(crate) fields: EditValue,
|
||||
}
|
||||
|
||||
pub(crate) fn edit_header(source: &str) -> Result<BTreeMap<&'static str, Span>, Box<Diagnostic>> {
|
||||
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
||||
let named = tokens
|
||||
.first()
|
||||
.is_none_or(|token| token.text(source) != "node");
|
||||
let roles: &[&str] = if named {
|
||||
&["kind", "alias", "name", "id_keyword", "id"]
|
||||
} else {
|
||||
&["kind", "alias", "id_keyword", "id"]
|
||||
};
|
||||
if tokens.len() <= roles.len() {
|
||||
return Err(Box::new(diagnostic(
|
||||
source,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: 0,
|
||||
message: "Expected a complete stabilized declaration header.".into(),
|
||||
},
|
||||
)));
|
||||
}
|
||||
Ok(roles
|
||||
.iter()
|
||||
.zip(tokens)
|
||||
.map(|(role, token)| (*role, token.span))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diagnostic>> {
|
||||
let unit = parse_unit(source, false, ParseLimits::UNLIMITED)?;
|
||||
if unit.declarations.len() != 1 || !unit.includes.is_empty() {
|
||||
return Err(Box::new(diagnostic(
|
||||
source,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: 0,
|
||||
message: "Expected one declaration for a source edit.".into(),
|
||||
},
|
||||
)));
|
||||
}
|
||||
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
||||
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
||||
let mut stack = Vec::new();
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
match token.text(source) {
|
||||
"{" | "[" => stack.push(index),
|
||||
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
struct Positions<'a> {
|
||||
tokens: &'a [Lexeme],
|
||||
ends: Vec<usize>,
|
||||
}
|
||||
impl Positions<'_> {
|
||||
fn token(&self, start: usize) -> usize {
|
||||
self.tokens
|
||||
.partition_point(|token| token.span.start < start)
|
||||
}
|
||||
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, ast)| {
|
||||
let value = self.value(ast);
|
||||
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
||||
let end = self.tokens[self.token(value.span.end)].span.end;
|
||||
(
|
||||
key.clone(),
|
||||
EditField {
|
||||
span: Span { start, end },
|
||||
value,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn value(&self, ast: &Ast) -> EditValue {
|
||||
let index = self.token(ast.offset());
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
end: match ast {
|
||||
Ast::Reference(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
};
|
||||
let kind = match ast {
|
||||
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
||||
Ast::Array(items, _) => {
|
||||
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
||||
}
|
||||
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
||||
_ => EditKind::Atom,
|
||||
};
|
||||
EditValue { span, kind }
|
||||
}
|
||||
}
|
||||
let positions = Positions {
|
||||
tokens: &tokens,
|
||||
ends,
|
||||
};
|
||||
let body = tokens
|
||||
.iter()
|
||||
.position(|token| token.text(source) == "{")
|
||||
.expect("declaration body");
|
||||
let fields = EditValue {
|
||||
span: Span {
|
||||
start: tokens[body].span.start,
|
||||
end: positions.ends[body],
|
||||
},
|
||||
kind: EditKind::Record(positions.fields(&unit.declarations[0].fields)),
|
||||
};
|
||||
Ok(EditDeclaration {
|
||||
header: edit_header(source)?,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
struct Parser<'a> {
|
||||
source: &'a str,
|
||||
tokens: Vec<Lexeme>,
|
||||
|
||||
@@ -719,8 +719,9 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
}
|
||||
|
||||
/// Apply a model change to a compiled document workspace. Existing declaration
|
||||
/// ownership and untouched bytes remain stable; only changed declarations are
|
||||
/// canonicalized. Alias edits also rewrite declarations that reference them.
|
||||
/// ownership and surviving syntax trivia remain stable. Changed values and
|
||||
/// headers receive token edits; canonical formatting is only used for new syntax.
|
||||
/// Alias edits also rewrite declarations that reference them.
|
||||
/// No files are deleted, even when their last declaration is removed.
|
||||
pub fn update_bundle(
|
||||
current: &CompiledSourceProject,
|
||||
@@ -761,34 +762,133 @@ pub fn update_bundle(
|
||||
let old = ¤t.decoded.project;
|
||||
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))
|
||||
.collect();
|
||||
let new_aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id))
|
||||
.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 text = after.get(id).map_or_else(String::new, |text| {
|
||||
replacement(text, &bundle.files[&location.path], location.span.start)
|
||||
});
|
||||
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) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
edits
|
||||
.entry(location.path.clone())
|
||||
.or_default()
|
||||
.push((location.span, text));
|
||||
.extend(local.into_iter().map(|(span, text)| {
|
||||
(
|
||||
Span {
|
||||
start: span.start + location.span.start,
|
||||
end: span.end + location.span.start,
|
||||
},
|
||||
text,
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
let location = ¤t.index.project_header;
|
||||
let source = &bundle.files[&location.path][location.span.start..location.span.end];
|
||||
let before = project_header(old);
|
||||
let after = project_header(project);
|
||||
let parse = |text| {
|
||||
language::edit_header(text).map_err(|error| {
|
||||
vec![problem(
|
||||
&bundle,
|
||||
&location.path,
|
||||
location.span,
|
||||
"source_edit",
|
||||
error.message,
|
||||
)]
|
||||
})
|
||||
};
|
||||
let update = SyntaxUpdate {
|
||||
source,
|
||||
before: &before,
|
||||
after: &after,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
&parse(source)?,
|
||||
&parse(&before)?,
|
||||
&parse(&after)?,
|
||||
&mut local,
|
||||
);
|
||||
edits
|
||||
.entry(location.path.clone())
|
||||
.or_default()
|
||||
.push((location.span, project_header(project)));
|
||||
.extend(local.into_iter().map(|(span, text)| {
|
||||
(
|
||||
Span {
|
||||
start: span.start + location.span.start,
|
||||
end: span.end + location.span.start,
|
||||
},
|
||||
text,
|
||||
)
|
||||
}));
|
||||
}
|
||||
if old.description != project.description {
|
||||
let field = format!("description {};", quote(&project.description));
|
||||
if let Some(location) = ¤t.index.project_description {
|
||||
edits
|
||||
.entry(location.path.clone())
|
||||
.or_default()
|
||||
.push((location.span, field));
|
||||
let value =
|
||||
significant(&bundle.files[&location.path][location.span.start..location.span.end])
|
||||
[1]
|
||||
.span;
|
||||
edits.entry(location.path.clone()).or_default().push((
|
||||
Span {
|
||||
start: location.span.start + value.start,
|
||||
end: location.span.start + value.end,
|
||||
},
|
||||
quote(&project.description),
|
||||
));
|
||||
} else {
|
||||
let location = ¤t.index.project_body;
|
||||
edits.entry(location.path.clone()).or_default().push((
|
||||
@@ -909,19 +1009,436 @@ fn unindent(text: &str) -> String {
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect()
|
||||
}
|
||||
fn replacement(text: &str, source: &str, start: usize) -> String {
|
||||
let prefix = source[..start].rsplit('\n').next().unwrap_or("");
|
||||
let indent = if prefix.chars().all(char::is_whitespace) {
|
||||
prefix
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let text = unindent(text);
|
||||
let text = text.trim_end().replace('\n', &format!("\n{indent}"));
|
||||
fn significant(source: &str) -> Vec<language::TokenSpan> {
|
||||
language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
type LocalEdits = Vec<(Span, String)>;
|
||||
fn erase_syntax(source: &str, span: Span, edits: &mut LocalEdits) {
|
||||
edits.extend(
|
||||
significant(&source[span.start..span.end])
|
||||
.into_iter()
|
||||
.map(|token| {
|
||||
(
|
||||
Span {
|
||||
start: span.start + token.span.start,
|
||||
end: span.start + token.span.end,
|
||||
},
|
||||
String::new(),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
fn trivia(source: &str) -> String {
|
||||
language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|token| &source[token.span.start..token.span.end])
|
||||
.collect()
|
||||
}
|
||||
fn changed_text(source: &str, span: Span, mut edits: LocalEdits) -> String {
|
||||
let mut result = source[span.start..span.end].to_owned();
|
||||
edits.sort_by_key(|(span, _)| (span.start, span.end));
|
||||
for (edit, text) in edits.into_iter().rev() {
|
||||
result.replace_range(edit.start - span.start..edit.end - span.start, &text);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn newline(source: &str) -> &'static str {
|
||||
if source.contains("\r\n") {
|
||||
text.replace('\n', "\r\n")
|
||||
"\r\n"
|
||||
} else {
|
||||
text
|
||||
"\n"
|
||||
}
|
||||
}
|
||||
fn indent_at(source: &str, at: usize) -> &str {
|
||||
let line = source[..at].rsplit('\n').next().unwrap_or("");
|
||||
&line[..line.len() - line.trim_start_matches([' ', '\t']).len()]
|
||||
}
|
||||
fn inserted_value(text: &str, indent: &str, source: &str) -> String {
|
||||
let lines: Vec<_> = text.lines().collect();
|
||||
let base = lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| line.len() - line.trim_start_matches(' ').len())
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let mut output = lines.first().copied().unwrap_or("").to_owned();
|
||||
for line in lines.iter().skip(1) {
|
||||
output.push_str(newline(source));
|
||||
output.push_str(indent);
|
||||
output.push_str(line.get(base..).unwrap_or(line));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, Id>,
|
||||
new_aliases: &'a BTreeMap<String, Id>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
&self,
|
||||
actual: &BTreeMap<&str, Span>,
|
||||
before: &BTreeMap<&str, Span>,
|
||||
after: &BTreeMap<&str, Span>,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
for (key, span) in actual {
|
||||
match after.get(key) {
|
||||
Some(next)
|
||||
if before.get(key).is_none_or(|old| {
|
||||
self.before[old.start..old.end] != self.after[next.start..next.end]
|
||||
}) =>
|
||||
{
|
||||
edits.push((*span, self.after[next.start..next.end].to_owned()))
|
||||
}
|
||||
None => erase_syntax(self.source, *span, edits),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(name) = after.get("name")
|
||||
&& !actual.contains_key("name")
|
||||
{
|
||||
let at = actual["id_keyword"].start;
|
||||
edits.push((
|
||||
Span { start: at, end: at },
|
||||
format!("{} ", &self.after[name.start..name.end]),
|
||||
));
|
||||
}
|
||||
}
|
||||
fn same(&self, before: &language::EditValue, after: &language::EditValue) -> bool {
|
||||
use language::EditKind;
|
||||
match (&before.kind, &after.kind) {
|
||||
(EditKind::Record(a), EditKind::Record(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter().all(|(key, value)| {
|
||||
b.get(key)
|
||||
.is_some_and(|other| self.same(&value.value, &other.value))
|
||||
})
|
||||
}
|
||||
(EditKind::List(a), EditKind::List(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| self.same(a, b))
|
||||
}
|
||||
_ => {
|
||||
self.before[before.span.start..before.span.end]
|
||||
== self.after[after.span.start..after.span.end]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn value(
|
||||
&self,
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
if before.is_some_and(|before| self.same(before, after)) {
|
||||
return;
|
||||
}
|
||||
match (&actual.kind, &after.kind) {
|
||||
(EditKind::Record(fields), EditKind::Record(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
if let EditKind::Record(fields) = &old.kind {
|
||||
Some(fields)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
if let EditKind::List(items) = &old.kind {
|
||||
Some(items.as_slice())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
.pop()
|
||||
.expect("reference alias");
|
||||
edits.push((
|
||||
Span {
|
||||
start: actual.span.start + token.span.start,
|
||||
end: actual.span.start + token.span.end,
|
||||
},
|
||||
alias.clone(),
|
||||
));
|
||||
}
|
||||
(EditKind::Atom, EditKind::Atom) => edits.push((
|
||||
actual.span,
|
||||
self.after[after.span.start..after.span.end].into(),
|
||||
)),
|
||||
_ => {
|
||||
erase_syntax(self.source, actual.span, edits);
|
||||
let text = inserted_value(
|
||||
&self.after[after.span.start..after.span.end],
|
||||
indent_at(self.source, actual.span.start),
|
||||
self.source,
|
||||
);
|
||||
edits.push((
|
||||
Span {
|
||||
start: actual.span.start,
|
||||
end: actual.span.start,
|
||||
},
|
||||
text,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(
|
||||
&self,
|
||||
span: Span,
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
if actual.len() == 1
|
||||
&& after.len() == 1
|
||||
&& let Some(before) = before.filter(|fields| fields.len() == 1)
|
||||
{
|
||||
let (old_key, old) = before.first_key_value().unwrap();
|
||||
let (new_key, new) = after.first_key_value().unwrap();
|
||||
if old_key != new_key
|
||||
&& old_key.starts_with(|c: char| c.is_ascii_uppercase())
|
||||
&& new_key.starts_with(|c: char| c.is_ascii_uppercase())
|
||||
&& let Some(field) = actual.get(old_key)
|
||||
{
|
||||
let key =
|
||||
significant(&self.source[field.span.start..field.value.span.start])[0].span;
|
||||
edits.push((
|
||||
Span {
|
||||
start: key.start + field.span.start,
|
||||
end: key.end + field.span.start,
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (key, field) in actual {
|
||||
if let Some(next) = after.get(key) {
|
||||
self.value(
|
||||
&field.value,
|
||||
before
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
edits,
|
||||
);
|
||||
} else {
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
let added: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|(key, field)| {
|
||||
!actual.contains_key(*key)
|
||||
&& before
|
||||
.and_then(|fields| fields.get(*key))
|
||||
.is_none_or(|old| !self.same(&old.value, &field.value))
|
||||
})
|
||||
.collect();
|
||||
if added.is_empty() {
|
||||
return;
|
||||
}
|
||||
let close = span.end - 1;
|
||||
let multiline = self.source[span.start..span.end].contains('\n');
|
||||
let indent = actual
|
||||
.values()
|
||||
.min_by_key(|field| field.span.start)
|
||||
.map(|field| indent_at(self.source, field.span.start).to_owned())
|
||||
.filter(|indent| !indent.is_empty())
|
||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||
let mut text = String::new();
|
||||
for (key, field) in added {
|
||||
let value = inserted_value(
|
||||
&self.after[field.value.span.start..field.value.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
);
|
||||
if multiline {
|
||||
text.push_str(&indent);
|
||||
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
||||
} else {
|
||||
text.push_str(&format!(" {key} {value}; "));
|
||||
}
|
||||
}
|
||||
let prefix = self.source[..close].rsplit('\n').next().unwrap_or("");
|
||||
let at = if multiline && prefix.chars().all(char::is_whitespace) {
|
||||
close - prefix.len()
|
||||
} else {
|
||||
close
|
||||
};
|
||||
if multiline && at == close && !prefix.is_empty() {
|
||||
text.insert_str(0, newline(self.source));
|
||||
}
|
||||
edits.push((Span { start: at, end: at }, text));
|
||||
}
|
||||
fn list(
|
||||
&self,
|
||||
span: Span,
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.before, self.old_aliases))
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.after, self.new_aliases))
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
&& new.iter().collect::<BTreeSet<_>>().len() == new.len()
|
||||
});
|
||||
let mapping: Vec<_> = if let Some((old, new)) = keyed {
|
||||
let positions: BTreeMap<_, _> = old
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, key)| (key, i))
|
||||
.collect();
|
||||
new.into_iter()
|
||||
.map(|key| positions.get(&key).copied())
|
||||
.collect()
|
||||
} else {
|
||||
(0..after.len())
|
||||
.map(|i| (i < actual.len()).then_some(i))
|
||||
.collect()
|
||||
};
|
||||
if actual.len() == after.len()
|
||||
&& mapping
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = span.start + 1;
|
||||
for (i, item) in actual.iter().enumerate() {
|
||||
let end = if let Some(next) = actual.get(i + 1) {
|
||||
item.span.end
|
||||
+ significant(&self.source[item.span.end..next.span.start])[0]
|
||||
.span
|
||||
.start
|
||||
} else {
|
||||
span.end - 1
|
||||
};
|
||||
chunks.push(Span { start, end });
|
||||
start = end + 1;
|
||||
}
|
||||
let multiline = self.source[span.start..span.end].contains('\n');
|
||||
let indent = actual
|
||||
.first()
|
||||
.map(|item| indent_at(self.source, item.span.start).to_owned())
|
||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||
let mut text = String::new();
|
||||
let mut retained = BTreeSet::new();
|
||||
for (i, next) in after.iter().enumerate() {
|
||||
let suffix;
|
||||
if let Some(index) = mapping[i].filter(|index| *index < actual.len()) {
|
||||
retained.insert(index);
|
||||
let current = &actual[index];
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
if multiline {
|
||||
text.push_str(newline(self.source));
|
||||
text.push_str(&indent);
|
||||
} else {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&inserted_value(
|
||||
&self.after[next.span.start..next.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
));
|
||||
suffix = "";
|
||||
}
|
||||
if i + 1 < after.len() {
|
||||
text.push(',');
|
||||
}
|
||||
text.push_str(suffix);
|
||||
}
|
||||
for (i, chunk) in chunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !retained.contains(i))
|
||||
{
|
||||
let _ = i;
|
||||
text.push_str(&trivia(&self.source[chunk.start..chunk.end]));
|
||||
}
|
||||
if actual.is_empty() {
|
||||
text.push_str(&self.source[span.start + 1..span.end - 1]);
|
||||
}
|
||||
edits.push((
|
||||
Span {
|
||||
start: span.start + 1,
|
||||
end: span.end - 1,
|
||||
},
|
||||
text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
match &value.kind {
|
||||
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
|
||||
EditKind::Atom => Some(format!(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::List(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -686,3 +686,385 @@ fn include_inspection_returns_located_failure_without_partial_discovery_and_is_b
|
||||
);
|
||||
assert_eq!(errors[0].diagnostic.code, "project_limit");
|
||||
}
|
||||
|
||||
fn comments(source: &str) -> Vec<String> {
|
||||
language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|token| token.class == language::TokenClass::Comment)
|
||||
.map(|token| source[token.span.start..token.span.end].to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
project.name = "Renamed project".into();
|
||||
project.description = "After".into();
|
||||
let route = project
|
||||
.relations
|
||||
.get_mut(&id(¤t.decoded.project, "route"))
|
||||
.unwrap();
|
||||
route.name = "Renamed transition".into();
|
||||
if let RelationBody::StateTransition { guard, .. } = &mut route.body {
|
||||
*guard = "new guard".into();
|
||||
} else {
|
||||
panic!("transition");
|
||||
}
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
result.bundle.files["project.arcadia"],
|
||||
current.bundle.files["project.arcadia"]
|
||||
.replace("\"Original project\"", "\"Renamed project\"")
|
||||
.replace("\"Before\"", "\"After\"")
|
||||
);
|
||||
assert_eq!(
|
||||
result.bundle.files["behavior.arcadia"],
|
||||
current.bundle.files["behavior.arcadia"]
|
||||
.replace("\"Original transition\"", "\"Renamed transition\"")
|
||||
.replace("\"old guard\"", "\"new guard\"")
|
||||
);
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
project
|
||||
.elements
|
||||
.get_mut(&id(¤t.decoded.project, "root_work"))
|
||||
.unwrap()
|
||||
.alias = "renamed_work".into();
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let expected = current.bundle.files["work.arcadia"]
|
||||
.replace("oa_activity root_work ", "oa_activity renamed_work ")
|
||||
.replace("\n root_work;", "\n renamed_work;");
|
||||
assert_eq!(result.bundle.files["work.arcadia"], expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
project
|
||||
.elements
|
||||
.get_mut(&id(¤t.decoded.project, "work"))
|
||||
.unwrap()
|
||||
.description = "New documentation".into();
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let before = ¤t.bundle.files["work.arcadia"];
|
||||
let after = &result.bundle.files["work.arcadia"];
|
||||
assert_eq!(
|
||||
after.replace("\tdescription \"New documentation\";\r\n", ""),
|
||||
*before
|
||||
);
|
||||
assert_eq!(comments(after), comments(before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
project
|
||||
.elements
|
||||
.get_mut(&id(¤t.decoded.project, "item"))
|
||||
.unwrap()
|
||||
.body = ElementBody::new(Kind::Entity, Layer::Oa).unwrap();
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let after = &result.bundle.files["item.arcadia"];
|
||||
assert!(after.contains("description \"Keep\\u0020this\" ; // description note"));
|
||||
assert!(after.contains("parent none;"));
|
||||
assert_eq!(
|
||||
comments(after),
|
||||
comments(¤t.bundle.files["item.arcadia"])
|
||||
);
|
||||
assert!(!significant_words(after).contains(&"Information".into()));
|
||||
}
|
||||
|
||||
fn significant_words(source: &str) -> Vec<String> {
|
||||
language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|token| source[token.span.start..token.span.end].to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
let timer = project
|
||||
.elements
|
||||
.get_mut(&id(¤t.decoded.project, "timer"))
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Event { body }) = timer.shared_mut() {
|
||||
*body = EventBody::Change {
|
||||
expression: "after".into(),
|
||||
};
|
||||
} else {
|
||||
panic!("event");
|
||||
}
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let after = &result.bundle.files["event.arcadia"];
|
||||
assert!(after.contains("Change // time rationale"));
|
||||
assert!(after.contains("expression // expression rationale\n \"after\" ;"));
|
||||
assert_eq!(
|
||||
comments(after),
|
||||
comments(¤t.bundle.files["event.arcadia"])
|
||||
);
|
||||
assert!(!significant_words(after).contains(&"After".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
let port = project
|
||||
.elements
|
||||
.get_mut(&id(¤t.decoded.project, "input"))
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Port { contract, legacy }) = port.shared_mut() {
|
||||
*contract = PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
};
|
||||
*legacy = None;
|
||||
} else {
|
||||
panic!("port");
|
||||
}
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
comments(&result.bundle.files["ports.arcadia"]),
|
||||
comments(¤t.bundle.files["ports.arcadia"])
|
||||
);
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
let a = id(&project, "a");
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b, a];
|
||||
} else {
|
||||
panic!("interface");
|
||||
}
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let after = &result.bundle.files["types.arcadia"];
|
||||
assert!(after.find("// before B").unwrap() < after.find("// before A").unwrap());
|
||||
assert!(after.contains("@ // A reference rationale\n renamed_a // after A"));
|
||||
let mut old_comments = comments(¤t.bundle.files["types.arcadia"]);
|
||||
let mut new_comments = comments(after);
|
||||
old_comments.sort();
|
||||
new_comments.sort();
|
||||
assert_eq!(old_comments, new_comments);
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
let mut project = current.decoded.project.clone();
|
||||
let a = id(&project, "a");
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.remove(&a);
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b];
|
||||
} else {
|
||||
panic!("interface");
|
||||
}
|
||||
let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
let mut before = comments(¤t.bundle.files["types.arcadia"]);
|
||||
let mut after = comments(&result.bundle.files["types.arcadia"]);
|
||||
before.sort();
|
||||
after.sort();
|
||||
assert_eq!(before, after);
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_message_value_edits_preserve_field_comments_and_reordering_uses_field_identity() {
|
||||
let source = r#"oa_activity send "Send" {} oa_activity receive "Receive" {}
|
||||
oa_exchange_item data "Data" {}
|
||||
oa_data_field first_value "First" { parent @data; }
|
||||
oa_data_field second_value "Second" { parent @data; }
|
||||
interaction transfer "Transfer" { source @send; target @receive; items [@data]; }
|
||||
oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@message]; }
|
||||
oa_lifeline sender_line "Sender" { parent @conversation; represented @send; }
|
||||
oa_lifeline receiver_line "Receiver" { parent @conversation; represented @receive; }
|
||||
oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
exchange @transfer; sender @sender_line; receiver @receiver_line;
|
||||
values [
|
||||
// first annotation
|
||||
{ field @ // first field proof
|
||||
first_value; expression // first expression proof
|
||||
"first expression"; },
|
||||
// second annotation
|
||||
{ expression "second expression"; field @second_value; }
|
||||
];
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
let message = id(&project, "message");
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&message).unwrap().shared_mut()
|
||||
{
|
||||
values[0].expression = "changed expression".into();
|
||||
} else {
|
||||
panic!("message");
|
||||
}
|
||||
let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
changed.bundle.files["conversation.arcadia"],
|
||||
current.bundle.files["conversation.arcadia"]
|
||||
.replace("\"first expression\"", "\"changed expression\"")
|
||||
);
|
||||
let first = id(&project, "first_value");
|
||||
project.elements.get_mut(&first).unwrap().alias = "renamed_value".into();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&message).unwrap().shared_mut()
|
||||
{
|
||||
values.reverse();
|
||||
}
|
||||
let reordered = update_bundle(&changed, &project, ProjectLimits::default()).unwrap();
|
||||
let after = &reordered.bundle.files["conversation.arcadia"];
|
||||
assert!(after.contains("field @ // first field proof\n renamed_value; expression // first expression proof\n \"changed expression\";"));
|
||||
assert!(
|
||||
after.find("// second annotation").unwrap() < after.find("// first annotation").unwrap()
|
||||
);
|
||||
let mut old_comments = comments(¤t.bundle.files["conversation.arcadia"]);
|
||||
let mut new_comments = comments(after);
|
||||
old_comments.sort();
|
||||
new_comments.sort();
|
||||
assert_eq!(old_comments, new_comments);
|
||||
for (id, location) in ¤t.index.declarations {
|
||||
assert_eq!(reordered.index.declarations[id].path, location.path);
|
||||
}
|
||||
assert_eq!(reordered.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let empty = compile(&input);
|
||||
let mut project = empty.decoded.project.clone();
|
||||
let values = id(&project, "values");
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
literals: vec!["Alpha".into(), "Beta".into()],
|
||||
};
|
||||
} else {
|
||||
panic!("enumeration");
|
||||
}
|
||||
let added = update_bundle(&empty, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
comments(&added.bundle.files["types.arcadia"]),
|
||||
comments(&empty.bundle.files["types.arcadia"])
|
||||
);
|
||||
let mut annotated = added.bundle.clone();
|
||||
annotated
|
||||
.files
|
||||
.get_mut("types.arcadia")
|
||||
.unwrap()
|
||||
.clone_from(
|
||||
&added.bundle.files["types.arcadia"]
|
||||
.replace("\"Alpha\"", "\"Alpha\" // alpha explanation\n")
|
||||
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
|
||||
);
|
||||
let annotated = compile(&annotated);
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
literals: vec!["Beta".into(), "Gamma".into()],
|
||||
};
|
||||
}
|
||||
let changed = update_bundle(&annotated, &project, ProjectLimits::default()).unwrap();
|
||||
let mut before = comments(&annotated.bundle.files["types.arcadia"]);
|
||||
let mut after = comments(&changed.bundle.files["types.arcadia"]);
|
||||
before.sort();
|
||||
after.sort();
|
||||
assert_eq!(before, after);
|
||||
assert_eq!(changed.decoded.project, project);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user