feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+196 -74
View File
@@ -898,6 +898,10 @@ pub(crate) struct Declaration {
fields: BTreeMap<String, Ast>,
offset: usize,
pub(crate) span: Span,
/// The containing element's identity, independent of an authored parent field.
pub(crate) lexical_owner: Option<Id>,
/// Exact body interior, excluding the opening and closing braces.
pub(crate) body: Span,
pub(crate) id_insertion: Option<usize>,
pub(crate) field_spans: BTreeMap<String, Span>,
}
@@ -952,6 +956,9 @@ pub(crate) enum EditKind {
List(Vec<EditValue>),
}
pub(crate) struct EditDeclaration {
pub(crate) span: Span,
pub(crate) lexical_owner: Option<Id>,
pub(crate) body: Span,
pub(crate) header: BTreeMap<&'static str, Span>,
pub(crate) fields: EditValue,
}
@@ -995,6 +1002,27 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
},
)));
}
let mut declarations = edit_positions(source, unit)?;
Ok(declarations.pop().expect("one declaration").1)
}
/// Editing positions for every occurrence in lexical preorder. This is syntax
/// analysis only: intermediate command edits may have duplicate identities or
/// parent fields that disagree with their as-yet unmoved lexical owner. Callers
/// retain occurrence provenance across such edits, then compile the final text.
pub(crate) fn edit_declarations(
source: &str,
entry: bool,
limits: ParseLimits,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let unit = parse_unit(source, entry, limits)?;
edit_positions(source, unit)
}
fn edit_positions(
source: &str,
unit: SourceUnit,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
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();
@@ -1055,21 +1083,48 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
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,
})
unit.declarations
.into_iter()
.map(|declaration| {
if declaration.id_insertion.is_some() {
return Err(Box::new(diagnostic(
source,
Error {
code: "invalid_source",
offset: declaration.span.start,
message: "Stabilize declaration identities before editing source.".into(),
},
)));
}
let roles: &[&str] = if matches!(declaration.kind, DeclKind::Node) {
&["kind", "alias", "id_keyword", "id"]
} else {
&["kind", "alias", "name", "id_keyword", "id"]
};
let start = positions.token(declaration.span.start);
let header = roles
.iter()
.zip(&tokens[start..])
.map(|(role, token)| (*role, token.span))
.collect();
Ok((
declaration.id,
EditDeclaration {
span: declaration.span,
lexical_owner: declaration.lexical_owner,
body: declaration.body,
header,
fields: EditValue {
span: Span {
start: declaration.body.start - 1,
end: declaration.body.end + 1,
},
kind: EditKind::Record(positions.fields(&declaration.fields)),
},
},
))
})
.collect()
}
struct Parser<'a> {
source: &'a str,
@@ -1143,17 +1198,8 @@ impl Parser<'_> {
self.take("{")?;
let mut fields = BTreeMap::new();
while self.text() != "}" {
let key = self.text().to_owned();
let offset = self.current().span.start;
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let value = self.value()?;
self.take(";")?;
let (key, value, _) = self.field()?;
if fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
@@ -1165,6 +1211,36 @@ impl Parser<'_> {
self.take("}")?;
Ok(fields)
}
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
let key = self.text().to_owned();
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let start = self.current().span.start;
let value = self.value()?;
let end = self.tokens[self.cursor - 1].span.end;
self.take(";")?;
Ok((key, value, Span { start, end }))
}
fn nested_header_kind(&self) -> Option<DeclKind> {
let kind = declaration_kind(self.text())?;
let alias = self.tokens.get(self.cursor + 1)?;
let following = self.tokens.get(self.cursor + 2)?;
// A node's ordinary `diagram @view;` field is not a declaration.
if !crate::valid_alias(alias.text(self.source)) {
return None;
}
if matches!(kind, DeclKind::Node) {
matches!(following.text(self.source), "id" | "{").then_some(kind)
} else {
(following.class == TokenClass::String).then_some(kind)
}
}
fn value(&mut self) -> Result<Ast, Error> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
@@ -1301,15 +1377,54 @@ impl Parser<'_> {
} else {
None
};
let mut declarations = Vec::new();
let mut declarations: Vec<Declaration> = Vec::new();
let mut includes = Vec::new();
let mut graph_count = 0usize;
while if entry {
self.text() != "}"
} else {
!self.text().is_empty()
} {
// Declarations are a flat preorder with a stack of open body indices.
// Semantic nesting can be deep without recursing through the Rust stack
// or conflating declaration depth with the typed-value depth budget.
let mut open: Vec<usize> = Vec::new();
loop {
let offset = self.current().span.start;
if let Some(&owner) = open.last() {
if self.text() == "}" {
declarations[owner].body.end = offset;
declarations[owner].span.end = self.current().span.end;
self.cursor += 1;
open.pop();
continue;
}
if self.text().is_empty() {
return Err(self.error("Expected a field, element declaration, or '}'."));
}
if self.text() == "include" {
return Err(
self.error("Includes must occur at document level, outside declarations.")
);
}
if let Some(kind) = self.nested_header_kind() {
if !matches!(declarations[owner].kind, DeclKind::Element(..))
|| !matches!(kind, DeclKind::Element(..))
{
return Err(self
.error("Only element declarations may nest inside an element body."));
}
} else {
let (key, value, span) = self.field()?;
let declaration = &mut declarations[owner];
if declaration.fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: format!("Duplicate field '{key}'."),
});
}
declaration.field_spans.insert(key, span);
continue;
}
} else if (entry && self.text() == "}") || (!entry && self.text().is_empty()) {
break;
}
if self.text() == "include" {
if !includes_allowed {
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
@@ -1326,7 +1441,8 @@ impl Parser<'_> {
));
continue;
}
if self.text() == "description"
if open.is_empty()
&& self.text() == "description"
&& let Some(mut entry_header) = header.take()
{
if entry_header.description.is_some() {
@@ -1370,41 +1486,29 @@ impl Parser<'_> {
};
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
let id = self.optional_id()?;
let fields = self.fields()?;
let field_spans = fields
.iter()
.map(|(key, value)| {
let start = value.offset();
let mut depth = 0usize;
let mut end = start;
for token in
self.tokens[self.tokens.partition_point(|t| t.span.start < start)..].iter()
{
match token.text(self.source) {
";" if depth == 0 => break,
"{" | "[" => depth += 1,
"}" | "]" => depth = depth.saturating_sub(1),
_ => {}
}
end = token.span.end;
}
(key.clone(), Span { start, end })
})
.collect();
let body_start = self.current().span.end;
self.take("{")?;
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
declarations.push(Declaration {
kind,
alias,
name,
id,
fields,
fields: BTreeMap::new(),
offset,
span: Span {
start: offset,
end: self.tokens[self.cursor - 1].span.end,
end: body_start,
},
lexical_owner,
body: Span {
start: body_start,
end: body_start,
},
id_insertion,
field_spans,
field_spans: BTreeMap::new(),
});
open.push(declarations.len() - 1);
}
if entry {
header.as_mut().expect("entry header").body.end = self.current().span.start;
@@ -1449,8 +1553,19 @@ pub(crate) fn resolve_declarations(
DeclKind::Element(kind, layer) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
let explicit_parent = d.fields.contains_key("parent");
let mut parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
if let Some(owner) = d.lexical_owner {
if explicit_parent && parent != Some(owner) {
return Err(Error {
code: "conflicting_parent",
offset: d.fields["parent"].offset(),
message: "Explicit parent must equal the containing element; move the declaration to change its lexical parent.".into(),
});
}
parent = Some(owner);
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1667,6 +1782,21 @@ pub fn analyze(source: &str) -> Analysis {
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
.collect::<Vec<_>>();
// Recover matched body bounds once. Scanning each aggregate's descendants
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
}
}
_ => {}
}
}
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -1709,23 +1839,11 @@ pub fn analyze(source: &str) -> Analysis {
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
let mut depth = 0usize;
for next in significant.iter().skip(j) {
match &source[next.span.start..next.span.end] {
"{" => depth += 1,
"}" => {
if depth == 0 {
break;
}
depth -= 1;
if depth == 0 {
end = next.span.end;
break;
}
}
_ => {}
}
end = next.span.end;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
@@ -1744,6 +1862,10 @@ pub fn analyze(source: &str) -> Analysis {
}
}
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
pub(crate) mod legacy {
//! Arcadia text: a readable, lossless representation of a valid OA project.
//!
@@ -0,0 +1,505 @@
use super::*;
use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bundle};
fn project_source(body: &str) -> String {
format!(
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, value)| *value == alias)
.unwrap()
.0
}
#[test]
fn nested_elements_infer_only_parent_and_keep_typed_fields_and_allocations_explicit() {
let source = project_source(
r#"oa_entity team "Team" {
description "Authored before children";
oa_actor operator_one "Operator" {}
oa_role responder "Responder" {}
}
oa_activity respond "Respond" {
oa_activity report "Report" {}
description "Authored after children";
}
sa_system system_one "System" {
sa_port boundary "Boundary" { contract { Component { kind Standard; orientation Unset; }; }; }
}
sa_function work "Work" {
sa_function assess "Assess" {
sa_port result "Result" { contract { FunctionOutput { outgoing_items [@data]; }; }; }
}
}
sa_exchange_item data "Data" {
sa_data_field latitude "Latitude" { type_ref Real; unit "degrees"; }
}
allocation performs "Performs" { source @system_one; target @assess; }"#,
);
let project = parse(&source).unwrap();
for (child, owner) in [
("operator_one", "team"),
("responder", "team"),
("report", "respond"),
("boundary", "system_one"),
("assess", "work"),
("result", "assess"),
("latitude", "data"),
] {
assert_eq!(
project.elements[&identity(&project, child)].parent,
Some(identity(&project, owner))
);
}
assert_eq!(project.relations.len(), 1);
let relation = &project.relations[&identity(&project, "performs")];
assert_eq!(relation.kind(), RelationKind::Allocation);
assert_eq!(relation.target(), identity(&project, "assess"));
assert_eq!(parse(&format(&project)).unwrap(), project);
assert_eq!(
project.elements[&identity(&project, "respond")].description,
"Authored after children"
);
}
#[test]
fn matching_explicit_parent_is_accepted_but_none_or_another_owner_is_located() {
let nested = project_source(
"oa_activity parent_one \"Parent\" { oa_activity child \"航海\" { parent @parent_one; } }\noa_activity other \"Other\" {}",
);
let project = parse(&nested).unwrap();
assert_eq!(
project.elements[&identity(&project, "child")].parent,
Some(identity(&project, "parent_one"))
);
for reference in ["none", "@other"] {
let invalid = nested
.replace("parent @parent_one;", &format!("parent {reference};"))
.replace('\n', "\r\n");
let errors = parse(&invalid).unwrap_err();
let issue = errors
.iter()
.find(|issue| issue.code == "conflicting_parent")
.unwrap();
let at = invalid.find(&format!("parent {reference};")).unwrap() + "parent ".len();
assert_eq!(issue.span.start, at);
assert_eq!(issue.line, 2);
assert_eq!(
issue.column,
invalid[..at].rsplit('\n').next().unwrap().chars().count() + 1
);
}
}
#[test]
fn containment_validation_rejects_allocation_like_or_cross_layer_nesting() {
for body in [
"sa_system system_one \"System\" { sa_function work \"Work\" {} }",
"oa_activity parent_one \"Parent\" { sa_function child \"Child\" {} }",
] {
let source = project_source(body);
let errors = parse(&source).unwrap_err();
assert!(errors.iter().any(|issue| issue.code == "invalid_parent"));
}
}
#[test]
fn non_elements_includes_and_declarations_inside_typed_values_are_rejected() {
for body in [
"oa_activity outer_one \"Outer\" { allocation a \"A\" { source @outer_one; target @outer_one; } }",
"oa_activity outer_one \"Outer\" { include \"children.arcadia\"; }",
"diagram view_one \"View\" { oa_activity child \"Child\" {} }",
"sa_port port_one \"Port\" { contract { sa_function child \"Child\" {} }; }",
] {
let source = project_source(body);
assert!(parse_unit(&source, true, ParseLimits::UNLIMITED).is_err());
}
// Existing node `diagram @view;` fields must not look like nested headers.
let demo = Project::demo();
assert_eq!(parse(&format(&demo)).unwrap(), demo);
}
#[test]
fn declarations_are_preorder_occurrences_with_real_own_field_and_body_spans() {
let source = "// lead\r\noa_activity outer_one \"航海\" id \"00000000-0000-0000-0000-000000000002\" {\r\n description \"before\";\r\n oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000003\" { description \"inside\"; } // child proof\r\n}\r\n";
let unit = parse_unit(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 2);
let outer = &unit.declarations[0];
let child = &unit.declarations[1];
assert_eq!(outer.alias, "outer_one");
assert_eq!(outer.lexical_owner, None);
assert_eq!(child.lexical_owner, Some(outer.id));
assert_eq!(
&source[child.body.start..child.body.end],
" description \"inside\"; "
);
assert_eq!(&source[outer.body.start - 1..outer.body.start], "{");
assert_eq!(&source[outer.body.end..outer.body.end + 1], "}");
assert!(!child.field_spans.contains_key("parent"));
assert!(outer.body.start < child.span.start && child.span.end < outer.body.end);
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
let (_, outer_edit) = &edits[0];
let (_, child_edit) = &edits[1];
assert_eq!(child_edit.span, child.span);
assert_eq!(child_edit.body, child.body);
assert_eq!(child_edit.lexical_owner, Some(outer.id));
let EditKind::Record(fields) = &outer_edit.fields.kind else {
panic!("own fields");
};
assert_eq!(fields.len(), 1, "children must not appear as typed fields");
let value = &fields["description"].value;
assert_eq!(&source[value.span.start..value.span.end], "\"before\"");
assert_eq!(
&source[child_edit.header["alias"].start..child_edit.header["alias"].end],
"child"
);
}
#[test]
fn syntax_edit_positions_retain_duplicate_identity_occurrences_during_a_command() {
let source = "oa_activity outer_one \"Outer\" id \"00000000-0000-0000-0000-000000000002\" { oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000002\" { parent none; } }";
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].0, edits[1].0);
assert!(edits[0].1.span.start < edits[1].1.span.start);
assert!(parse(&project_source(source)).is_err());
}
#[test]
fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes() {
let original = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
"// rationale\r\nsa_function owner_one \"航海\" { sa_function leaf \"Leaf\" { } } // kept\r\n".into(),
),
]),
};
let compiled = compile_bundle(&original, ProjectLimits::default()).unwrap();
let stable = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, stable.bundle);
assert_eq!(compiled.decoded.project, stable.decoded.project);
assert_eq!(compiled.index, stable.index);
let child = identity(&compiled.decoded.project, "leaf");
assert!(
!compiled
.index
.fields
.contains_key(&(child, "parent".into()))
);
let location = &compiled.index.declarations[&child];
assert_eq!(location.path, "children.arcadia");
assert!(
compiled.bundle.files[&location.path][location.span.start..location.span.end]
.starts_with("sa_function leaf \"Leaf\" id ")
);
assert!(compiled.bundle.files["children.arcadia"].starts_with("// rationale\r\n"));
assert!(compiled.bundle.files["children.arcadia"].ends_with("// kept\r\n"));
let raw_warning = lint_bundle(&original, ProjectLimits::default())
.into_iter()
.find(|issue| issue.diagnostic.code == "unallocated_leaf")
.unwrap();
let raw = &original.files["children.arcadia"];
assert_eq!(
raw_warning.diagnostic.span.start,
raw.find("sa_function leaf").unwrap()
);
}
#[test]
fn nested_aliases_remain_global_and_forward_references_remain_legal() {
let source = project_source(
"oa_activity outer_one \"Outer\" { oa_activity child \"Child\" { parent @outer_one; } }\noa_activity sibling \"Sibling\" { parent @outer_one; }\noa_activity sa_function \"Alias using a declaration spelling\" {}",
);
let project = parse(&source).unwrap();
assert_eq!(
project.elements[&identity(&project, "sibling")].parent,
project.elements[&identity(&project, "child")].parent
);
let duplicated = source.replace("oa_activity sibling", "oa_activity child");
assert!(
parse(&duplicated)
.unwrap_err()
.iter()
.any(|d| d.message.contains("Duplicate alias"))
);
}
#[test]
fn nested_record_admission_precedes_resolution_and_typed_depth_stays_bounded() {
let source = project_source(
"oa_activity a \"A\" { oa_activity b \"B\" { oa_activity c \"C\" { oa_activity d \"D\" { parent @missing; } } } }",
);
let errors = parse_limited(
&source,
ParseLimits {
max_graph_objects: 3,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(errors[0].code, "model_limit");
assert_eq!(errors[0].span.start, source.find("oa_activity d").unwrap());
let deep_value = project_source(&format!(
"oa_activity owner_one \"Owner\" {{ description {}0{}; }}",
"[".repeat(65),
"]".repeat(65)
));
assert!(
parse(&deep_value).unwrap_err()[0]
.message
.contains("64 levels")
);
}
#[test]
fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
let mut fragment = String::new();
for i in 2..602u128 {
fragment.push_str(&format!(
"oa_activity level_{i} \"Level\" id \"{}\" {{\n",
Id::from_u128(i)
));
}
fragment.push_str(&"}\n".repeat(600));
let analyzed_source = project_source(&fragment);
std::thread::Builder::new()
.stack_size(128 * 1024)
.spawn(move || {
let unit = parse_unit(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 600);
assert_eq!(
unit.declarations[599].lexical_owner,
Some(Id::from_u128(600))
);
let edits = edit_declarations(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 600);
})
.unwrap()
.join()
.unwrap();
let analysis = analyze(&analyzed_source);
assert_eq!(analysis.declarations.len(), 601);
assert!(
!analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source);
let ready = analysis
.declarations
.iter()
.find(|d| d.alias == "ready")
.unwrap();
assert_eq!(
&source[ready.span.start..ready.span.end],
"oa_activity ready \"Ready\" { description \"} is text\"; }"
);
let outer = analysis
.declarations
.iter()
.find(|d| d.alias == "outer_one")
.unwrap();
assert_eq!(outer.span.end, source.len());
assert!(
analysis
.declarations
.iter()
.any(|d| d.alias == "unfinished")
);
assert!(
analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn normative_nested_ownership_example_compiles_as_a_source_fragment() {
let spec = include_str!("../../../docs/language.rst");
let section = spec.split("6.2. Nested element ownership").nth(1).unwrap();
let example = section
.split("For example::")
.nth(1)
.unwrap()
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| line.starts_with(" "))
.map(|line| &line[4..])
.collect::<Vec<_>>()
.join("\n");
assert!(!example.is_empty());
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
project_source("include \"example.arcadia\";"),
),
("example.arcadia".into(), example),
]),
},
ProjectLimits::default(),
)
.unwrap();
let project = &compiled.decoded.project;
assert_eq!(
project.elements[&identity(project, "radio")].parent,
Some(identity(project, "harbor"))
);
}
fn checked_in_source_project() -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia",
include_str!("../../../examples/source-project/project.arcadia"),
),
(
"oa.arcadia",
include_str!("../../../examples/source-project/oa.arcadia"),
),
(
"sa/participants.arcadia",
include_str!("../../../examples/source-project/sa/participants.arcadia"),
),
(
"sa/reporting.arcadia",
include_str!("../../../examples/source-project/sa/reporting.arcadia"),
),
(
"sa/assurance.arcadia",
include_str!("../../../examples/source-project/sa/assurance.arcadia"),
),
(
"views.arcadia",
include_str!("../../../examples/source-project/views.arcadia"),
),
]
.into_iter()
.map(|(path, text)| (path.into(), text.into()))
.collect(),
}
}
#[test]
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
let project = &compiled.decoded.project;
assert_eq!(project.diagrams.len(), 3);
let assess = identity(project, "assess_report");
let actor_port = identity(project, "reporting_output");
assert_eq!(
project.elements[&assess].parent,
Some(identity(project, "handle_reports"))
);
assert_eq!(
project.elements[&actor_port].parent,
Some(identity(project, "vessel_service"))
);
assert!(matches!(
port_contract(&project.elements[&actor_port]),
Some(PortContract::Component { .. })
));
let exchange = &project.relations[&identity(project, "validated_report")];
assert_eq!(exchange.kind(), RelationKind::FunctionalExchange);
assert_eq!(exchange.target(), assess);
for diagram in project.diagrams.values() {
let view = projection::project_diagram(project, diagram);
assert!(!view.nodes.is_empty());
let routed = routing::route_diagram(project, diagram, &view);
assert!(!routed.edges.is_empty());
for edge in routed.edges.values() {
assert!(
edge.warnings.is_empty(),
"{} / {}: {:?}",
diagram.name,
project.named(edge.id),
edge.warnings
);
}
}
}
#[test]
fn checked_in_example_supports_cross_file_reparent_without_changing_allocations() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
let project = &compiled.decoded.project;
let assess = identity(project, "assess_report");
let prepared = prepare_command(
project,
&Command::Reparent {
element: assess,
parent: Some(identity(project, "assure_response")),
},
)
.unwrap();
assert_eq!(prepared.project.relations, project.relations);
let result = crate::source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
);
assert_eq!(
result.index.declarations[&identity(project, "report_in")].path,
"sa/assurance.arcadia"
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment rationale travels with this declaration")
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment remains performed by rescue_system")
);
assert!(
result.bundle.files["sa/reporting.arcadia"]
.contains("// This group comment stays in this file")
);
for path in [
"project.arcadia",
"oa.arcadia",
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], input.files[path]);
}
}
+88 -133
View File
@@ -4,6 +4,8 @@ use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_structure.rs"]
mod source_structure;
/// A portable slash-separated path, checked at every bundle boundary.
pub type LogicalPath = String;
@@ -88,6 +90,11 @@ pub struct IncludeLocation {
pub struct SourceIndex {
/// Includes the project header itself under the project UUID.
pub declarations: BTreeMap<Id, SourceLocation>,
/// Interior of each declaration body, excluding its opening/closing braces.
pub declaration_bodies: BTreeMap<Id, SourceLocation>,
/// Authored element nesting, distinct from an explicit semantic parent field.
/// Flat declarations have no entry; the project is not a lexical element owner.
pub lexical_owners: BTreeMap<Id, Id>,
/// Complete value spans for top-level declaration fields.
pub fields: BTreeMap<(Id, String), SourceLocation>,
pub includes: Vec<IncludeLocation>,
@@ -363,6 +370,8 @@ fn compile_once(
let mut alias_locations: BTreeMap<String, SourceLocation> = BTreeMap::new();
let mut project_header = None;
let mut declaration_locations = BTreeMap::new();
let mut declaration_bodies = BTreeMap::new();
let mut lexical_owners = BTreeMap::new();
let mut fields = BTreeMap::new();
let mut includes = Vec::new();
let mut insertions: Edits = BTreeMap::new();
@@ -461,6 +470,16 @@ fn compile_once(
aliases.insert(declaration.alias.clone(), declaration.id);
alias_locations.insert(declaration.alias.clone(), location.clone());
declaration_locations.insert(declaration.id, location);
declaration_bodies.insert(
declaration.id,
SourceLocation {
path: path.clone(),
span: declaration.body,
},
);
if let Some(owner) = declaration.lexical_owner {
lexical_owners.insert(declaration.id, owner);
}
for (key, span) in &declaration.field_spans {
fields.insert(
(declaration.id, key.clone()),
@@ -532,6 +551,8 @@ fn compile_once(
let header = project_header.expect("validated entry header");
let index = SourceIndex {
declarations: declaration_locations,
declaration_bodies,
lexical_owners,
fields,
includes,
project_header: SourceLocation {
@@ -847,63 +868,65 @@ pub fn update_bundle_with_identity_map(
.collect();
let mut bundle = current.bundle.clone();
let mut edits: Edits = BTreeMap::new();
for (id, old_text) in &before {
let location = &current.index.declarations[id];
let new_text = identities.get(id).and_then(|id| after.get(id));
if new_text != Some(old_text) {
let source = &bundle.files[&location.path][location.span.start..location.span.end];
let mut local = Vec::new();
if let Some(new_text) = new_text {
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
&location.path,
location.span,
"source_edit",
error.message,
)]
})
};
let actual = parse(source)?;
let previous = parse(old_text)?;
let next = parse(new_text)?;
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
update.header(&actual.header, &previous.header, &next.header, &mut local);
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
&mut local,
);
} else {
erase_syntax(
source,
Span {
start: 0,
end: source.len(),
},
&mut local,
);
let mut original_order = BTreeMap::new();
for (path, source) in &bundle.files {
let declarations = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *error,
related: Vec::new(),
}]
})?;
original_order.insert(
path.clone(),
declarations.iter().map(|(id, _)| *id).collect(),
);
for (id, actual) in declarations {
let old_text = &before[&id];
// Deletion is structural: removing a parent's complete span here
// would overlap child edits and lose independently moved descendants.
let Some(new_text) = identities.get(&id).and_then(|id| after.get(id)) else {
continue;
};
if new_text == old_text {
continue;
}
edits
.entry(location.path.clone())
.or_default()
.extend(local.into_iter().map(|(span, text)| {
(
Span {
start: span.start + location.span.start,
end: span.end + location.span.start,
},
text,
)
}));
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
path,
actual.span,
"source_edit",
error.message,
)]
})
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
{
// The lexical structure is the authored definition of parent.
// Keep it implicit while the structural phase moves this record.
for fields in [&mut previous.fields, &mut next.fields] {
if let language::EditKind::Record(fields) = &mut fields.kind {
fields.remove("parent");
}
}
}
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -975,84 +998,16 @@ pub fn update_bundle_with_identity_map(
}
}
let defaults = default_ownership(project);
let mut assigned: BTreeMap<_, _> = current
.index
.declarations
.iter()
.filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone())))
.collect();
let mut chosen_defaults = BTreeMap::new();
let mut used_paths: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
let mut additions: BTreeMap<LogicalPath, String> = BTreeMap::new();
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(*id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
for id in new_ids {
let mut parent = semantic_owner(project, id);
let mut seen = BTreeSet::new();
let mut inherited = None;
while let Some(owner) = parent {
if !seen.insert(owner) {
break;
}
if let Some(path) = assigned.get(&owner) {
inherited = Some(path.clone());
break;
}
parent = semantic_owner(project, owner);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[&id].clone())
.or_insert_with(|| {
let preferred = &defaults[&id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used_paths)
}
})
.clone()
});
assigned.insert(id, path.clone());
additions
.entry(path)
.or_default()
.push_str(&unindent(&after[&id]));
}
let mut new_includes = String::new();
for (path, text) in additions {
if !bundle.files.contains_key(&path) {
bundle.files.insert(path.clone(), String::new());
new_includes.push_str(&format!(" include {};\n", quote(&path)));
}
let at = if path == bundle.entry {
current.index.project_body.span.end
} else {
bundle.files[&path].len()
};
edits
.entry(path)
.or_default()
.push((Span { start: at, end: at }, format!("\n{text}")));
}
if !new_includes.is_empty() {
let at = current.index.project_body.span.end;
edits
.entry(bundle.entry.clone())
.or_default()
.push((Span { start: at, end: at }, format!("\n{new_includes}")));
}
apply_edits(&mut bundle, edits)?;
source_structure::update(
&mut bundle,
current,
project,
identities,
&original_order,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
if &result.decoded.project != project {
return Err(vec![problem(
@@ -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)
}
+10 -1
View File
@@ -1021,6 +1021,15 @@ pub fn plan_transition(
Ok(plan)
}
pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Project, ModelError> {
prepare_transition(project, plan).map(|prepared| prepared.project)
}
/// Prepare a reviewed transition once, retaining identity correspondence for
/// adapters that update accepted graphical and authored source state together.
pub fn prepare_transition(
project: &Project,
plan: &TransitionPlan,
) -> Result<PreparedCommand, ModelError> {
if project_fingerprint(project) != plan.base_fingerprint {
return Err(ModelError(
"Transition preview is stale; create a new preview.".into(),
@@ -1031,7 +1040,7 @@ pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Proj
"Resolve transition conflicts before applying.".into(),
));
}
execute_batch(project, &plan.commands)
prepare_command(project, &Command::Batch(plan.commands.clone()))
}
/// Supported explicit element counterparts, in stable kind-catalog order.