Preserve source comments and layout through model token edits

This commit is contained in:
2026-09-05 19:54:31 +03:00
parent c5788fc15e
commit dbc8971dd2
5 changed files with 1109 additions and 41 deletions
+137
View File
@@ -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>,
+539 -22
View File
@@ -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 = &current.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 = &current.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 = &current.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) = &current.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 = &current.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,
}
}
+382
View File
@@ -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(&current.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(&current, &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(&current.decoded.project, "root_work"))
.unwrap()
.alias = "renamed_work".into();
let result = update_bundle(&current, &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(&current.decoded.project, "work"))
.unwrap()
.description = "New documentation".into();
let result = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
let before = &current.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(&current.decoded.project, "item"))
.unwrap()
.body = ElementBody::new(Kind::Entity, Layer::Oa).unwrap();
let result = update_bundle(&current, &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(&current.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(&current.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(&current, &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(&current.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(&current.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(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(
comments(&result.bundle.files["ports.arcadia"]),
comments(&current.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(&current, &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(&current.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(&current, &project, ProjectLimits::default()).unwrap();
let mut before = comments(&current.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(&current, &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(&current.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 &current.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);
}
+38 -10
View File
@@ -908,7 +908,7 @@ folders consolidate into category modules to avoid thousands of tiny files.
For every valid P, ``compile_bundle(format_bundle(P)).decoded.project == P``
when the caller supplies sufficient admission budgets.
``update_bundle(compiled, P, limits)`` is a core prototype update facility. It
``update_bundle(compiled, P, limits)`` is a core source update facility. It
checks that the compiled document/index/model snapshot still agrees, preserves
existing declaration file ownership and all untouched document bytes, and
recompiles the result before returning it. New children inherit their owner's
@@ -917,15 +917,43 @@ Removed declarations do not delete source files. Alias changes also update
declarations containing references to that alias. Invalid or stale updates
return errors without mutating the input workspace.
The current update facility canonicalizes each changed declaration in full.
It therefore DOES NOT preserve inline comments or custom layout inside that
declaration, even for a single scalar edit. This is an explicit prototype
limitation, not final authoring/save acceptance. A lossless project editor needs
minimal header/field/value edits that preserve unrelated tokens and comments;
the available source spans provide the starting point. Canonical modular export
is a distinct, explicit operation and does not promise original comment/layout
preservation. No native multi-file editor, project save transaction or REST
bundle endpoint is claimed by the pure core APIs.
Updates MUST NOT canonicalize surviving declarations in full. Header, field and
nested value positions are derived from the existing syntax parser. Unchanged
fields retain their exact bytes, including field order, omitted defaults, number
and string spelling, comments, indentation and line endings. Changed literals and
references replace only the required tokens; a reference with comments between
``@`` and its alias retains those comments. A previously omitted default is
inserted only if its semantic value changes. New fields use the existing record's
indentation and line-ending style without reformatting its other fields.
A constructor tag change retains shared nested fields in place. Removed fields,
constructors and declarations remove syntax tokens, retaining their comments
and whitespace as annotations in the enclosing record or file. A complete value
shape replacement likewise retains the old value's comments beside the new value.
The updater does not invent an engineering interpretation for orphaned comments.
List entries use stable referenced UUIDs, message-value ``field`` references or
lifeline-binding ``referenced`` identities when these keys are unique. Literal
entries use their literal value. Retained entries carry their authored value
text and inter-separator comments when reordered. Trivia after a preceding comma
belongs to the following entry; trivia before the next comma belongs to the
preceding entry. Separators are reconstructed before trailing line comments so
that the result remains valid syntax. Unkeyed or duplicate-key lists use source
positions. Removed-entry comments remain as annotations in the list; newly added
entries receive formatted syntax. These rules preserve comments without silently
attaching new semantic identity to an ambiguous list entry.
Retained declaration UUIDs and source ownership remain unchanged. Records match
by UUID, not display name or alias; an explicit UUID replacement is a removal
and creation, retaining the removed record's comments in its original file. The
result is recompiled, reindexed and checked for equality with the requested
model before return. Source changes needed for undo are workspace history data;
callers should retain complete compiled workspace snapshots rather than infer
past omission/layout choices from a model-only undo.
Canonical modular export is a distinct, explicit operation and does not promise
original comment/layout preservation. No native multi-file editor, project save
transaction or REST bundle endpoint is claimed by the pure core APIs.
``parse`` returns a complete structurally validated candidate or error
diagnostics and no model. Engineering warnings alone MUST NOT reject a valid
+13 -9
View File
@@ -10,7 +10,7 @@ Status: pure source3 subsystem implemented and tested. This is not native projec
- Portable relative includes, root containment, canonical map keys, case collisions, repeated includes, cycles, missing/unreachable files, duplicate JSON file keys and aggregate admission checks.
- Generated identities are inserted into their original headers. Returned diagnostics/index address the resulting stabilized buffers. Caller drafts are never mutated.
- Canonical modules follow layers, work roots, behavior contexts and diagrams. Ports follow their owners; placements follow diagrams; relations follow their sources. Existing ownership is retained during model updates. Huge sets of independent roots consolidate by category rather than produce thousands of files.
- Prototype model updates preserve untouched files/text, rewrite changed declarations and alias uses, add new owner/root declarations and includes, and retain empty files after deletion. Every update recompiles and checks exact semantic equality before return.
- Model updates preserve authored comments, whitespace and surviving fields, change header/value tokens and alias uses, add new owner/root declarations and includes, and retain empty files after deletion. Every update recompiles and checks exact semantic equality before return.
## Consumer interface
@@ -34,23 +34,27 @@ For native discovery, `inspect_includes(path, source, is_entry, limits)` uses th
`CompiledSourceProject.decoded.migration` is `None` for source3 bundles. Legacy import remains `language::parse_with_report` / `persistence::decode_with_report`; retain that report in the importing UI, then explicitly export the migrated model into source3 modules. Do not independently migrate fragments or fabricate a migration report during subsequent source3 compilation.
## Explicit authoring limitation and next seam
## Comment-preserving update policy
`update_bundle` currently canonicalizes an entire changed declaration. It preserves comments outside that declaration and all untouched document bytes, but **drops inline comments/custom layout inside a changed declaration**, including for a scalar-only change. This is a tested core candidate, not the adopted final project-save policy. `format_bundle` is an explicit canonical export and intentionally does not reproduce authored trivia.
The earlier whole-declaration canonicalization limitation is fixed. `update_bundle` derives language-specific editing positions from the existing validated AST and tokenizer. The shared V2 AST and migration behavior are unchanged; no alternate grammar, persistent syntax model or generic CST framework was added.
The bounded next improvement is a lossless update representation, not a second semantic model:
- Old/new canonical typed values identify semantic changes; surviving authored fields retain their exact text, field order, comments, omitted defaults and escape/number spelling. Header and scalar changes replace only the affected tokens. Reference aliases change without removing comments between `@` and the alias.
- New fields insert at the owning record using existing indentation/line endings. Omitted defaults stay omitted unless their semantic value changes. Constructor tag changes preserve shared nested fields.
- Removing a field, constructor or declaration erases its significant syntax tokens, retaining comments/whitespace as annotations in the parent record or file. Complete value-shape replacements retain the old value's comments next to the replacement. The updater does not guess how orphaned engineering notes should be reworded.
- Lists match unique referenced UUIDs, message-value `field` references, binding `referenced` identities, or literal values. Retained entries move with their inter-separator trivia; removed-entry comments remain in the enclosing list. Other/duplicate-key lists use positions. Trivia after a comma belongs to the next entry; trivia before a comma belongs to the previous entry. Separators are emitted before trailing line comments to keep syntax valid.
- Declaration identity is UUID-based; no alias/name heuristic silently treats a new UUID as an old record. Explicit UUID replacement removes/creates records while retaining removed-record comments as file annotations. Existing UUIDs and file ownership remain unchanged for retained records.
- `format_bundle` remains an explicit canonical export, not the update/save path. Keep compiled workspace snapshots for undo/redo; model-only undo cannot recover prior omissions or authored layout choices.
1. Extend parser-derived locations to header alias/name/identity tokens, nested record keys/values, list items, and closing-brace insertion points. Existing top-level field value spans already distinguish unchanged fields.
2. Diff old/new typed values using field paths, then replace only changed literal/reference tokens. Preserve field order, spelling/trivia of unaffected fields, and comments between tokens. Adding a field inserts at the owning record; an omitted default needs insertion only when its semantic value changes.
3. For constructor/list structural edits, use keyed record/list changes with explicit trivia retention. Do not silently fall back to replacing the whole record and call it lossless. Keep explicit canonical export separate.
4. Add exact-byte regressions for guard/name/message edits with comments before, within and after the changed record, reordered authored fields, omitted defaults, nested scenario message values, alias uses and CRLF source. Verify model equality and the unchanged bytes in every case.
This production core correction does not itself establish complete human-authoring, project-editor or native save acceptance. Experimental nested grammar remains separate and unintegrated.
## Verification
- `cargo test -p oa-core`: **150 tests passed**, including 20 new source-project tests.
- `cargo test -p oa-core`: **160 tests passed**, including 30 source-project tests.
- `cargo clippy -p oa-core --all-targets -- -D warnings`: passed.
- Owned Rust files formatted with `rustfmt --edition 2024 --config skip_children=true`.
The source-project tests cover cross-file typed-port/exchange references, Unicode syntax/structural diagnostics, duplicate declarations with related locations, include diamonds/cycles/missing/unreachable files, portable paths, aggregate byte/object/record/step/file/depth/edge budgets, JSON duplicate keys, UUID stabilization and adjacent tokens, declaration relocation, alias propagation, owner inheritance, new module includes, retained empty files, stale/invalid update rejection, canonical case collisions and bounded large-root output. Full demo equality covers behavior, artifacts, trace baselines and placements. Moving a placement changes only its owning view document.
Ten additional regressions verify exact CRLF/name/guard/project-metadata updates, Unicode escape spelling and comments after `@`, omitted defaults and field insertion, kind/constructor changes with removed-field comments, scalar/record value-shape replacements, reference and literal list changes, deleted-record comments, and nested scenario message-value edits/reordering by field identity. These tests check exact unchanged bytes or the retained comment set as appropriate, plus complete model equality and source ownership.
No native CUA, editor, server, project-directory loader/save journal, port metamodel or Session changes were performed in this subsystem. Those integration milestones remain open. `docs/language.rst` sections 6.1 and 7 distinguish implemented core behavior from these pending authoring/integration requirements; the obsolete source2 grammar production is corrected to source3.