//! Pure, bounded source projects. All includes resolve against supplied UTF-8 //! documents; this module never reads a filesystem, URL, or process directory. use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation}; use language::{Diagnostic, ParseLimits, Span}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; #[path = "source_project/source_migration.rs"] mod source_migration; #[path = "source_project/source_structure.rs"] mod source_structure; /// A portable slash-separated path, checked at every bundle boundary. pub type LogicalPath = String; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SourceBundle { pub entry: LogicalPath, #[serde(deserialize_with = "unique_files")] pub files: BTreeMap, } fn unique_files<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { struct Files; impl<'de> serde::de::Visitor<'de> for Files { type Value = BTreeMap; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("unique logical source paths mapped to UTF-8 text") } fn visit_map>( self, mut access: A, ) -> Result { let mut files = BTreeMap::new(); while let Some((path, source)) = access.next_entry::()? { if files.insert(path.clone(), source).is_some() { return Err(serde::de::Error::custom(format!( "Duplicate source path '{path}'." ))); } } Ok(files) } } deserializer.deserialize_map(Files) } #[derive(Debug, Clone, Copy)] pub struct ProjectLimits { pub max_files: usize, /// Number of include edges from the entry to a document; the entry is zero. pub max_include_depth: usize, pub max_include_edges: usize, /// Existing language budgets, applied to the whole project, never per file. pub model: ParseLimits, } impl Default for ProjectLimits { fn default() -> Self { Self { max_files: 256, max_include_depth: 32, max_include_edges: 1_024, model: ParseLimits { max_source_bytes: 64 * 1024 * 1024, ..ParseLimits::UNLIMITED }, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceLocation { pub path: LogicalPath, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceDiagnostic { pub path: LogicalPath, pub diagnostic: Diagnostic, pub related: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncludeLocation { pub location: SourceLocation, pub target: LogicalPath, } /// Derived positions in `CompiledSourceProject::bundle`, including inserted IDs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceIndex { /// Includes the project header itself under the project UUID. pub declarations: BTreeMap, /// Interior of each declaration body, excluding its opening/closing braces. pub declaration_bodies: BTreeMap, /// 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, /// Complete value spans for top-level declaration fields. pub fields: BTreeMap<(Id, String), SourceLocation>, pub includes: Vec, pub project_header: SourceLocation, pub project_body: SourceLocation, /// The complete `description ...;` field, when explicitly present. pub project_description: Option, } #[derive(Debug, Clone)] pub struct CompiledSourceProject { /// Exact input text except for explicit UUIDs inserted into missing headers. pub bundle: SourceBundle, pub decoded: DecodeResult, pub index: SourceIndex, /// Located engineering warnings. Errors are returned instead of a candidate. pub diagnostics: Vec, } /// Normalize an include relative to its containing file. Bundle keys themselves /// must already be canonical. Case collisions are rejected separately. pub fn resolve_include(from: &str, include: &str) -> Result { if !from.is_empty() && resolve_include("", from).as_deref() != Ok(from) { return Err("The containing document path is not canonical.".into()); } if include.is_empty() || include.starts_with('/') || include.contains(['\\', ':', '<', '>', '"', '|', '?', '*']) || include.chars().any(char::is_control) { return Err("Expected a relative portable source path.".into()); } let mut parts: Vec<&str> = from .rsplit_once('/') .map_or_else(Vec::new, |(dir, _)| dir.split('/').collect()); for part in include.split('/') { match part { "" => return Err("Empty path segments are not allowed.".into()), "." => {} ".." => { if parts.pop().is_none() { return Err("Include escapes the source project root.".into()); } } _ => { let base = part.split('.').next().unwrap_or("").to_ascii_uppercase(); if part.ends_with(['.', ' ']) || matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && matches!(base.as_bytes()[3], b'1'..=b'9')) { return Err("Path contains a non-portable file name.".into()); } parts.push(part); } } } let path = parts.join("/"); if !path.ends_with(".arcadia") { return Err("Source documents must have the .arcadia suffix.".into()); } Ok(path) } fn problem( bundle: &SourceBundle, path: &str, span: Span, code: &'static str, message: impl Into, ) -> SourceDiagnostic { let source = bundle.files.get(path).map_or("", String::as_str); located_problem(path, source, span, code, message) } fn located_problem( path: &str, source: &str, span: Span, code: &'static str, message: impl Into, ) -> SourceDiagnostic { let mut diagnostic = language::diagnostic( source, language::Error { code, offset: span.start, message: message.into(), }, ); if span.end <= source.len() && source.is_char_boundary(span.end) { diagnostic.span.end = span.end.max(diagnostic.span.start); } SourceDiagnostic { path: path.into(), diagnostic, related: Vec::new(), } } /// Inspect one entry or fragment before its included documents are available. /// Uses the same strict syntax parser as compilation, never a token/string scan. /// A syntax error returns located diagnostics and no partial include list; /// global references and engineering validity are checked by `compile_bundle`. /// Callers must additionally enforce aggregate budgets while discovering files. pub fn inspect_includes( path: &str, source: &str, is_entry: bool, limits: ProjectLimits, ) -> Result, Vec> { let fail = |code, message| { vec![located_problem( path, source, Span::default(), code, message, )] }; if resolve_include("", path).as_deref() != Ok(path) || (is_entry && path.contains('/')) { return Err(fail( "invalid_path", "Non-canonical source document path.".into(), )); } if limits.max_files == 0 { return Err(fail( "project_limit", "The file admission budget is zero.".into(), )); } if source.len() > limits.model.max_source_bytes { return Err(fail( "source_limit", format!("Source exceeds {} bytes.", limits.model.max_source_bytes), )); } let unit = language::parse_unit(source, is_entry, limits.model).map_err(|diagnostic| { vec![SourceDiagnostic { path: path.into(), diagnostic: *diagnostic, related: Vec::new(), }] })?; let mut includes = Vec::new(); for (raw, span) in unit.includes { if includes.len() >= limits.max_include_edges { return Err(vec![located_problem( path, source, span, "project_limit", format!("Include graph exceeds {} edges.", limits.max_include_edges), )]); } let target = resolve_include(path, &raw).map_err(|message| { vec![located_problem(path, source, span, "invalid_path", message)] })?; includes.push(IncludeLocation { location: SourceLocation { path: path.into(), span, }, target, }); } Ok(includes) } type Edits = BTreeMap>; /// Diagnose the exact editable bytes, without inserting omitted UUIDs. Locations /// refer to the supplied bundle, including when successful compilation would /// otherwise stabilize identities before returning its index and diagnostics. pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec { match compile_once(bundle, limits) { Ok((compiled, _)) => compiled.diagnostics, Err(diagnostics) => diagnostics, } } /// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs. /// Missing/unreachable files and every admission or engineering error leave the /// input untouched. V1/V2 single-file migration remains in `language::parse`. pub fn compile_bundle( bundle: &SourceBundle, limits: ProjectLimits, ) -> Result> { let (mut compiled, insertions) = compile_once(bundle, limits)?; if !insertions.is_empty() { let mut stable = bundle.clone(); apply_edits(&mut stable, insertions)?; // Re-index actual returned bytes before performing the schema upgrade. compiled = compile_once(&stable, limits)?.0; } if compiled.decoded.migration.is_some() { return source_migration::upgrade(&compiled, limits); } Ok(compiled) } fn compile_once( bundle: &SourceBundle, limits: ProjectLimits, ) -> Result<(CompiledSourceProject, Edits), Vec> { let fail = |code, message| { vec![problem( bundle, &bundle.entry, Span::default(), code, message, )] }; if bundle.entry.contains('/') { return Err(fail( "invalid_path", "The entry file must be at the source project root.".into(), )); } if bundle.files.len() > limits.max_files { return Err(fail( "project_limit", format!("Source project exceeds {} files.", limits.max_files), )); } let bytes = bundle .files .values() .fold(0usize, |sum, text| sum.saturating_add(text.len())); if bytes > limits.model.max_source_bytes { return Err(fail( "source_limit", format!( "Combined source exceeds {} bytes.", limits.model.max_source_bytes ), )); } let mut folded = BTreeMap::new(); for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) { if resolve_include("", path).as_ref() != Ok(path) { return Err(fail( "invalid_path", format!("Non-canonical source path '{path}'."), )); } if let Some(previous) = folded.insert(path.to_lowercase(), path) && previous != path { return Err(fail( "duplicate_path", format!("Case-colliding source paths '{previous}' and '{path}'."), )); } } if !bundle.files.contains_key(&bundle.entry) { return Err(fail( "missing_include", "The entry file is missing from the source bundle.".into(), )); } enum Visit { Enter(LogicalPath, usize, Option), Leave, } let mut stack = vec![Visit::Enter(bundle.entry.clone(), 0, None)]; let mut active: Vec = Vec::new(); let mut visited = BTreeSet::new(); let mut declarations = Vec::new(); let mut declaration_paths = Vec::new(); let mut aliases = BTreeMap::new(); let mut alias_locations: BTreeMap = 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(); let mut graph_count = 0usize; while let Some(visit) = stack.pop() { let Visit::Enter(path, depth, origin) = visit else { active.pop(); continue; }; let at = origin.unwrap_or(SourceLocation { path: path.clone(), span: Span::default(), }); if let Some(start) = active.iter().position(|file| file == &path) { let chain = active[start..] .iter() .chain(std::iter::once(&path)) .cloned() .collect::>() .join(" -> "); return Err(vec![problem( bundle, &at.path, at.span, "include_cycle", format!("Include cycle: {chain}."), )]); } if depth > limits.max_include_depth { return Err(vec![problem( bundle, &at.path, at.span, "project_limit", format!("Include depth exceeds {}.", limits.max_include_depth), )]); } if !visited.insert(path.clone()) { continue; } let Some(source) = bundle.files.get(&path) else { return Err(vec![problem( bundle, &at.path, at.span, "missing_include", format!("Included file '{path}' is missing."), )]); }; let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err( |diagnostic| { vec![SourceDiagnostic { path: path.clone(), diagnostic: *diagnostic, related: Vec::new(), }] }, )?; if let Some(header) = unit.header.take() { let location = SourceLocation { path: path.clone(), span: header.span, }; aliases.insert(header.project.alias.clone(), header.project.id); alias_locations.insert(header.project.alias.clone(), location.clone()); declaration_locations.insert(header.project.id, location); if let Some(offset) = header.id_insertion { insert_id(&mut insertions, &path, source, offset, header.project.id); } project_header = Some(header); } for declaration in unit.declarations { let location = SourceLocation { path: path.clone(), span: declaration.span, }; let duplicate = alias_locations .get(&declaration.alias) .map(|previous| ("alias", declaration.alias.clone(), previous)) .or_else(|| { declaration_locations .get(&declaration.id) .map(|previous| ("identity", declaration.id.to_string(), previous)) }); if let Some((kind, value, previous)) = duplicate { let mut issue = problem( bundle, &path, declaration.span, "duplicate_declaration", format!("Duplicate {kind} '{value}'."), ); issue.related.push(previous.clone()); return Err(vec![issue]); } 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()), SourceLocation { path: path.clone(), span: *span, }, ); } if let Some(offset) = declaration.id_insertion { insert_id(&mut insertions, &path, source, offset, declaration.id); } graph_count += usize::from(declaration.is_graph_object()); limits .model .check_counts(graph_count, declarations.len().saturating_add(2), 0) .map_err(|message| { vec![problem( bundle, &path, declaration.span, "model_limit", message, )] })?; declarations.push(declaration); declaration_paths.push(path.clone()); } let mut children = Vec::new(); for (raw, span) in unit.includes { let target = resolve_include(&path, &raw) .map_err(|message| vec![problem(bundle, &path, span, "invalid_path", message)])?; let location = SourceLocation { path: path.clone(), span, }; includes.push(IncludeLocation { location: location.clone(), target: target.clone(), }); if includes.len() > limits.max_include_edges { return Err(vec![problem( bundle, &path, span, "project_limit", format!("Include graph exceeds {} edges.", limits.max_include_edges), )]); } children.push(Visit::Enter( target, depth.saturating_add(1), Some(location), )); } active.push(path); stack.push(Visit::Leave); stack.extend(children.into_iter().rev()); } if let Some(path) = bundle.files.keys().find(|path| !visited.contains(*path)) { return Err(vec![problem( bundle, path, Span::default(), "unreachable_file", "Supplied source file is not reachable from the entry file.", )]); } let header = project_header.expect("validated entry header"); let index = SourceIndex { declarations: declaration_locations, declaration_bodies, lexical_owners, fields, includes, project_header: SourceLocation { path: bundle.entry.clone(), span: header.span, }, project_body: SourceLocation { path: bundle.entry.clone(), span: header.body, }, project_description: header.description.map(|span| SourceLocation { path: bundle.entry.clone(), span, }), }; let decoded = language::resolve_declarations(header.project, &declarations, &aliases, limits.model) .map_err(|(index, error)| { let path = declaration_paths.get(index).unwrap_or(&bundle.entry); vec![SourceDiagnostic { path: path.clone(), diagnostic: language::diagnostic(&bundle.files[path], error), related: Vec::new(), }] })?; let diagnostics = model_diagnostics(bundle, &index, &decoded.project); let errors: Vec<_> = diagnostics .iter() .filter(|issue| issue.diagnostic.severity == validation::Severity::Error) .cloned() .collect(); if !errors.is_empty() { return Err(errors); } Ok(( CompiledSourceProject { bundle: bundle.clone(), decoded, index, diagnostics, }, insertions, )) } fn model_diagnostics( bundle: &SourceBundle, index: &SourceIndex, project: &Project, ) -> Vec { let line_starts: BTreeMap<_, Vec<_>> = bundle .files .iter() .map(|(path, source)| { ( path, std::iter::once(0) .chain(source.match_indices('\n').map(|(offset, _)| offset + 1)) .collect(), ) }) .collect(); validation::validate(project) .into_iter() .map(|issue| { let location = issue .element .and_then(|id| { issue .field .as_ref() .and_then(|field| index.fields.get(&(id, field.clone()))) .or_else(|| index.declarations.get(&id)) }) .unwrap_or(&index.project_header); let source = &bundle.files[&location.path]; let starts = &line_starts[&location.path]; let line = starts.partition_point(|start| *start <= location.span.start); SourceDiagnostic { path: location.path.clone(), related: Vec::new(), diagnostic: Diagnostic { severity: issue.severity, code: issue.code, object_id: issue.element, field: issue.field, message: issue.message, span: location.span, line, column: source[starts[line - 1]..location.span.start] .chars() .count() + 1, }, } }) .collect() } fn insert_id(edits: &mut Edits, path: &str, source: &str, offset: usize, id: Id) { let space = if source[..offset] .chars() .next_back() .is_some_and(char::is_whitespace) { "" } else { " " }; edits.entry(path.into()).or_default().push(( Span { start: offset, end: offset, }, format!("{space}id \"{id}\" "), )); } fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec> { for (path, changes) in edits { let source = &bundle.files[&path]; let mut grouped: BTreeMap<(usize, usize), String> = BTreeMap::new(); for (span, text) in changes { if span.start > span.end || span.end > source.len() || !source.is_char_boundary(span.start) || !source.is_char_boundary(span.end) { return Err(vec![problem( bundle, &path, Span::default(), "stale_source_index", "Source edit positions no longer match the document.", )]); } grouped .entry((span.start, span.end)) .or_default() .push_str(&text); } let mut end = 0; for &(start, next_end) in grouped.keys() { if start < end { return Err(vec![problem( bundle, &path, Span::default(), "stale_source_index", "Source edits overlap.", )]); } end = next_end; } let source = bundle.files.get_mut(&path).expect("indexed file"); for ((start, end), text) in grouped.into_iter().rev() { source.replace_range(start..end, &text); } } Ok(()) } /// Canonical, readable current-version export. Like `language::format`, the caller is /// responsible for supplying a valid model; compilation checks the result. /// Existing source layout is preserved by `update_bundle`, not this formatter. pub fn format_bundle(project: &Project) -> SourceBundle { let ownership = default_ownership(project); let aliases: BTreeMap<_, _> = project.identities().into_iter().collect(); let mut declarations = language::format_declarations(project); declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases)); let mut files: BTreeMap = BTreeMap::new(); for (id, declaration) in declarations { files .entry(ownership[&id].clone()) .or_default() .push_str(&unindent(&declaration)); } let mut entry = format!( "arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n", project_header(project), quote(&project.description) ); for path in files.keys() { entry.push_str(&format!(" include {};\n", quote(path))); } entry.push_str("}\n"); files.insert("project.arcadia".into(), entry); SourceBundle { entry: "project.arcadia".into(), files, } } /// Apply a model change to a compiled document workspace. Existing declaration /// 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, project: &Project, limits: ProjectLimits, ) -> Result> { let identities = current .decoded .project .identities() .into_iter() .filter(|(id, _)| project.contains(*id)) .map(|(id, _)| (id, id)) .collect(); update_bundle_with_identity_map(current, project, &identities, limits) } /// Preserve declaration and reference-comment ownership through identity edits. /// `identities` maps every surviving original identity to its candidate identity; /// new records have no entry. Use the map from `prepare_command`, including for /// nested batches and identity swaps. An arbitrary model replacement without /// command provenance can use `update_bundle`'s ordinary same-UUID matching. pub fn update_bundle_with_identity_map( current: &CompiledSourceProject, project: &Project, identities: &BTreeMap, limits: ProjectLimits, ) -> Result> { // Public document/index fields are convenient for callers, but cannot become // an unchecked editing authority if a caller has changed either of them. let checked = compile_bundle(¤t.bundle, limits)?; if checked.index != current.index || checked.decoded.project != current.decoded.project || checked.bundle != current.bundle { return Err(vec![problem( ¤t.bundle, ¤t.bundle.entry, Span::default(), "stale_source_index", "Compile the current documents before applying a model update.", )]); } let errors: Vec<_> = model_diagnostics(¤t.bundle, ¤t.index, project) .into_iter() .filter(|issue| issue.diagnostic.severity == validation::Severity::Error) .collect(); if !errors.is_empty() { return Err(errors); } limits.model.check_model(project).map_err(|message| { vec![problem( ¤t.bundle, ¤t.bundle.entry, Span::default(), "model_limit", message, )] })?; let old = ¤t.decoded.project; // A supplied map is editing intent, never permission to create duplicate // records, refer to absent objects, or move syntax between record classes. let category = |model: &Project, id: Id| { if model.id == id { 0 } else if model.elements.contains_key(&id) { 1 } else if model.relations.contains_key(&id) { 2 } else if model.diagrams.contains_key(&id) { 3 } else if model.artifact_links.contains_key(&id) { 4 } else if model.traces.contains_key(&id) { 5 } else { 6 // Node placements; existence is checked separately. } }; if identities.values().collect::>().len() != identities.len() || identities.iter().any(|(before, after)| { !old.contains(*before) || !project.contains(*after) || category(old, *before) != category(project, *after) }) { return Err(vec![problem( ¤t.bundle, ¤t.bundle.entry, Span::default(), "invalid_identity_map", "Source identity correspondence must map existing records one-to-one within their record class.", )]); } let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect(); 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(), format!("original:{id}"))) .collect(); let new_aliases: BTreeMap<_, _> = project .identities() .into_iter() .map(|(id, alias)| { let identity = origins.get(&id).map_or_else( || format!("new:{id}"), |origin| format!("original:{origin}"), ); (alias.to_owned(), identity) }) .collect(); let mut bundle = current.bundle.clone(); let mut edits: Edits = BTreeMap::new(); 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; } 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, legacy_value_paths: false, identity_origins: Some(&origins), }; let local = edits.entry(path.clone()).or_default(); update.header(&actual.header, &previous.header, &next.header, local); let context = if old.diagrams.contains_key(&id) { EditContext::Diagram } else { EditContext::Other }; update.value( &actual.fields, Some(&previous.fields), &next.fields, context, local, ); } } if old.id != project.id || old.alias != project.alias || old.name != project.name { let location = ¤t.index.project_header; let source = &bundle.files[&location.path][location.span.start..location.span.end]; let before = project_header(old); let after = project_header(project); let parse = |text| { language::edit_header(text).map_err(|error| { vec![problem( &bundle, &location.path, location.span, "source_edit", error.message, )] }) }; let update = SyntaxUpdate { source, before: &before, after: &after, old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, identity_origins: Some(&origins), }; let mut local = Vec::new(); update.header( &parse(source)?, &parse(&before)?, &parse(&after)?, &mut local, ); 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, ) })); } if old.description != project.description { let field = format!("description {};", quote(&project.description)); if let Some(location) = ¤t.index.project_description { let value = significant(&bundle.files[&location.path][location.span.start..location.span.end]) [1] .span; edits.entry(location.path.clone()).or_default().push(( Span { start: location.span.start + value.start, end: location.span.start + value.end, }, quote(&project.description), )); } else { let location = ¤t.index.project_body; edits.entry(location.path.clone()).or_default().push(( Span { start: location.span.start, end: location.span.start, }, format!("\n {field}\n"), )); } } 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( &bundle, &bundle.entry, Span::default(), "source_update_mismatch", "Updated documents do not reproduce the requested model.", )]); } Ok(result) } fn quote(text: &str) -> String { serde_json::to_string(text).expect("string serialization") } fn project_header(project: &Project) -> String { format!( "project {} {} id \"{}\" ", project.alias, quote(&project.name), project.id ) } fn unindent(text: &str) -> String { text.split_inclusive('\n') .map(|line| line.strip_prefix(" ").unwrap_or(line)) .collect() } fn significant(source: &str) -> Vec { 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") { "\r\n" } else { "\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 } // Schema context is explicit: similarly named fields in arbitrary records must // never acquire Scenario row identity or move their authored trivia. #[derive(Clone, Copy)] enum EditContext { Other, Diagram, Layout, Scenario, Lanes, Messages, } impl EditContext { fn field(self, name: &str) -> Self { match (self, name) { (Self::Diagram, "layout") => Self::Layout, (Self::Layout, "Scenario") => Self::Scenario, (Self::Scenario, "lanes") => Self::Lanes, (Self::Scenario, "messages") => Self::Messages, _ => Self::Other, } } } struct SyntaxUpdate<'a> { source: &'a str, before: &'a str, after: &'a str, old_aliases: &'a BTreeMap, new_aliases: &'a BTreeMap, // Source3 migration only adds path qualification; the existing field identity // must keep its authored argument trivia during that one-time transformation. legacy_value_paths: bool, identity_origins: Option<&'a BTreeMap>, } 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, context: EditContext, 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, context, 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, context, 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::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => { let token = significant(&self.source[actual.span.start..actual.span.end]) .pop() .expect("historical UUID string"); edits.push(( Span { start: actual.span.start + token.span.start, end: actual.span.start + token.span.end, }, quote(&id.to_string()), )); } (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, before: Option<&BTreeMap>, after: &BTreeMap, context: EditContext, 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, context.field(new_key), 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, context.field(key), edits, ); } else if !(matches!(context, EditContext::Diagram) && key == "layout" && before.is_some_and(|fields| !fields.contains_key(key))) { // Canonical None omits layout. Preserve an authored explicit // none while another field changes; a real Some->None clear // still removes the former payload and retains its trivia. 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], context: EditContext, edits: &mut LocalEdits, ) { let previous = before.unwrap_or(&[]); let old_keys: Option> = previous .iter() .map(|item| { item_key( item, self.before, self.old_aliases, self.legacy_value_paths, None, context, ) }) .collect(); let new_keys: Option> = after .iter() .map(|item| { item_key( item, self.after, self.new_aliases, self.legacy_value_paths, self.identity_origins, context, ) }) .collect(); let keyed = old_keys.zip(new_keys).filter(|(old, new)| { old.iter().collect::>().len() == old.len() && new.iter().collect::>().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, EditContext::Other, 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, EditContext::Other, &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 scenario_item_key( value: &language::EditValue, aliases: &BTreeMap, context: EditContext, ) -> Option { use language::EditKind; let EditKind::Record(fields) = &value.kind else { return None; }; let reference = |value: &language::EditValue| { if let EditKind::Reference(alias) = &value.kind { aliases.get(alias).cloned() } else { None } }; match context { EditContext::Lanes => { reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}")) } EditContext::Messages => { let EditKind::Record(address) = &fields.get("address")?.value.kind else { return None; }; let occurrence = reference(&address.get("occurrence")?.value)?; let path: Vec<_> = match address.get("references") { None => Vec::new(), Some(field) => { let EditKind::List(path) = &field.value.kind else { return None; }; path.iter().map(reference).collect::>>()? } }; Some(format!("scenario-message:{}:{occurrence}", path.join("/"))) } _ => None, } } fn item_key( value: &language::EditValue, source: &str, aliases: &BTreeMap, legacy_value_paths: bool, origins: Option<&BTreeMap>, context: EditContext, ) -> Option { use language::EditKind; if matches!(context, EditContext::Lanes | EditContext::Messages) { return scenario_item_key(value, aliases, context); } if matches!(value.kind, EditKind::Record(_)) && let Some(key) = language::semantic_row_key( &source[value.span.start..value.span.end], origins.unwrap_or(&BTreeMap::new()), ) { return Some(key); } match &value.kind { EditKind::HistoricalUuid(id) => Some(format!( "uuid:{}", origins.and_then(|map| map.get(id)).unwrap_or(id) )), 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", "edge", "port"] .into_iter() .find_map(|key| { fields.get(key).and_then(|field| { if let EditKind::Reference(alias) = &field.value.kind { aliases.get(alias).map(|id| { if key == "field" && !legacy_value_paths { let path = fields .get("path") .and_then(|field| { if let EditKind::List(path) = &field.value.kind { path.iter() .map(|hop| { if let EditKind::Reference(alias) = &hop.kind { aliases.get(alias).cloned() } else { None } }) .collect::>>() } else { None } }) .unwrap_or_default(); format!("field:{}:{id}", path.join("/")) } else { format!("{key}:{id}") } }) } else { None } }) }), EditKind::List(_) => None, } } fn semantic_owner(project: &Project, id: Id) -> Option { if let Some(element) = project.elements.get(&id) { return element.parent; } if let Some(relation) = project.relations.get(&id) { return Some( relation .contextual_owner() .unwrap_or_else(|| relation.source()), ); } if let Some(artifact) = project.artifact_links.get(&id) { return Some(artifact.owner); } project .diagrams .values() .find(|diagram| diagram.nodes.contains_key(&id)) .map(|diagram| diagram.id) } fn default_module(project: &Project, id: Id) -> LogicalPath { if let Some(diagram) = project.diagrams.get(&id) { return format!("views/diagram_{}.arcadia", short_alias(&diagram.alias)); } if let Some(diagram) = project .diagrams .values() .find(|diagram| diagram.nodes.contains_key(&id)) { return format!("views/diagram_{}.arcadia", short_alias(&diagram.alias)); } if project.traces.contains_key(&id) { return "traceability.arcadia".into(); } let mut ancestors = Vec::new(); let mut current = Some(id); let mut seen = BTreeSet::new(); while let Some(id) = current { if !seen.insert(id) { break; } if let Some(element) = project.elements.get(&id) { ancestors.push(element); } current = semantic_owner(project, id); } let Some(element) = ancestors.first() else { return "artifacts.arcadia".into(); }; let layer = element.layer().to_string().to_lowercase(); if let Some(context) = ancestors.iter().find(|e| { matches!( e.kind(), Kind::Scenario | Kind::StateMachine | Kind::Process | Kind::FunctionalChain ) }) { let kind = match context.kind() { Kind::Scenario => "scenario", Kind::StateMachine => "state_machine", _ => "process", }; return format!( "{layer}/behavior/{kind}_{}.arcadia", short_alias(&context.alias) ); } if let Some(work) = ancestors .iter() .rev() .find(|e| matches!(e.kind(), Kind::Activity | Kind::Function)) { let (folder, prefix) = if work.kind() == Kind::Activity { ("activities", "activity") } else { ("functions", "function") }; return format!( "{layer}/{folder}/{prefix}_{}.arcadia", short_alias(&work.alias) ); } let root = ancestors.last().expect("nonempty ancestors"); let category = match root.kind() { Kind::Entity | Kind::Actor | Kind::Role | Kind::System | Kind::SystemActor => { "participants" } Kind::DataType | Kind::DataField | Kind::ExchangeItem | Kind::Interface => "types", Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => { "capabilities" } _ => "model", }; format!("{layer}/{category}.arcadia") } fn short_alias(alias: &str) -> String { alias.chars().take(64).collect() } fn unique_path(preferred: &str, used: &mut BTreeSet) -> LogicalPath { let mut result = preferred.to_owned(); let mut number = 2usize; while !used.insert(result.to_lowercase()) { result = format!( "{}_{number}.arcadia", preferred.trim_end_matches(".arcadia") ); number += 1; } result } fn default_ownership(project: &Project) -> BTreeMap { let mut ownership: BTreeMap<_, _> = project .identities() .into_iter() .filter(|(id, _)| *id != project.id) .map(|(id, _)| (id, default_module(project, id))) .collect(); let distinct: BTreeSet<_> = ownership.values().cloned().collect(); // Large sets of independent roots remain readable category modules instead // of producing thousands of tiny files or violating the default file cap. if distinct.len() > 240 { for path in ownership.values_mut() { if let Some((folder, _)) = path.rsplit_once('/') { *path = format!("{folder}/module.arcadia"); } } } let mut used = BTreeSet::from(["project.arcadia".into()]); let paths: BTreeSet<_> = ownership.values().cloned().collect(); let remap: BTreeMap<_, _> = paths .into_iter() .map(|path| { let chosen = unique_path(&path, &mut used); (path, chosen) }) .collect(); for path in ownership.values_mut() { *path = remap[path].clone(); } ownership } fn declaration_order(project: &Project, id: Id, aliases: &BTreeMap) -> (u8, Vec) { let rank = if project.elements.contains_key(&id) || project.diagrams.contains_key(&id) { 0 } else if project.relations.contains_key(&id) { 2 } else if project.artifact_links.contains_key(&id) { 3 } else if project.traces.contains_key(&id) { 4 } else { 1 }; let mut names = Vec::new(); let mut seen = BTreeSet::new(); let mut current = Some(id); while let Some(id) = current { if !seen.insert(id) { break; } names.push(aliases.get(&id).copied().unwrap_or("").to_owned()); current = semantic_owner(project, id); } names.reverse(); (rank, names) }