//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8 //! documents; this module never reads a filesystem, URL, or process directory. use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation}; use language::{Diagnostic, ParseLimits, Span}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; /// 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, /// 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>; /// Compile all reachable files as one source3 model and stabilize omitted 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 (compiled, insertions) = compile_once(bundle, limits)?; if insertions.is_empty() { return Ok(compiled); } let mut stable = bundle.clone(); apply_edits(&mut stable, insertions)?; // Re-index the actual returned bytes. Never expose offsets into pre-ID text. compile_once(&stable, limits).map(|(compiled, _)| 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 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); 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, 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 project = 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, &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: DecodeResult { project, migration: None, }, 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 source3 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 3;\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> { // 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; let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect(); let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect(); let old_aliases: BTreeMap<_, _> = old .identities() .into_iter() .map(|(id, alias)| (alias.to_owned(), id)) .collect(); let new_aliases: BTreeMap<_, _> = project .identities() .into_iter() .map(|(id, alias)| (alias.to_owned(), id)) .collect(); let mut bundle = current.bundle.clone(); let mut edits: Edits = BTreeMap::new(); for (id, old_text) in &before { let location = ¤t.index.declarations[id]; if after.get(id) != Some(old_text) { let source = &bundle.files[&location.path][location.span.start..location.span.end]; let mut local = Vec::new(); if let Some(new_text) = after.get(id) { let parse = |text| { language::edit_declaration(text).map_err(|error| { vec![problem( &bundle, &location.path, location.span, "source_edit", error.message, )] }) }; let actual = parse(source)?; let previous = parse(old_text)?; let next = parse(new_text)?; let update = SyntaxUpdate { source, before: old_text, after: new_text, old_aliases: &old_aliases, new_aliases: &new_aliases, }; update.header(&actual.header, &previous.header, &next.header, &mut local); update.value( &actual.fields, Some(&previous.fields), &next.fields, &mut local, ); } else { erase_syntax( source, Span { start: 0, end: source.len(), }, &mut local, ); } edits .entry(location.path.clone()) .or_default() .extend(local.into_iter().map(|(span, text)| { ( Span { start: span.start + location.span.start, end: span.end + location.span.start, }, text, ) })); } } if old.id != project.id || old.alias != project.alias || old.name != project.name { let location = ¤t.index.project_header; let source = &bundle.files[&location.path][location.span.start..location.span.end]; let before = project_header(old); let after = project_header(project); let parse = |text| { language::edit_header(text).map_err(|error| { vec![problem( &bundle, &location.path, location.span, "source_edit", error.message, )] }) }; let update = SyntaxUpdate { source, before: &before, after: &after, old_aliases: &old_aliases, new_aliases: &new_aliases, }; let mut local = Vec::new(); update.header( &parse(source)?, &parse(&before)?, &parse(&after)?, &mut local, ); edits .entry(location.path.clone()) .or_default() .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"), )); } } let defaults = default_ownership(project); let mut assigned: BTreeMap<_, _> = current .index .declarations .iter() .filter(|(id, _)| project.contains(**id)) .map(|(id, location)| (*id, 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 = BTreeMap::new(); let aliases: BTreeMap<_, _> = project.identities().into_iter().collect(); let mut new_ids: Vec<_> = after .keys() .filter(|id| !before.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)?; 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 } struct SyntaxUpdate<'a> { source: &'a str, before: &'a str, after: &'a str, old_aliases: &'a BTreeMap, new_aliases: &'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, edits: &mut LocalEdits, ) { use language::EditKind; if before.is_some_and(|before| self.same(before, after)) { return; } match (&actual.kind, &after.kind) { (EditKind::Record(fields), EditKind::Record(next)) => { let previous = before.and_then(|old| { if let EditKind::Record(fields) = &old.kind { Some(fields) } else { None } }); self.record(actual.span, fields, previous, next, edits); } (EditKind::List(items), EditKind::List(next)) => { let previous = before.and_then(|old| { if let EditKind::List(items) = &old.kind { Some(items.as_slice()) } else { None } }); self.list(actual.span, items, previous, next, edits); } (EditKind::Reference(_), EditKind::Reference(alias)) => { let token = significant(&self.source[actual.span.start..actual.span.end]) .pop() .expect("reference alias"); edits.push(( Span { start: actual.span.start + token.span.start, end: actual.span.start + token.span.end, }, alias.clone(), )); } (EditKind::Atom, EditKind::Atom) => edits.push(( actual.span, self.after[after.span.start..after.span.end].into(), )), _ => { erase_syntax(self.source, actual.span, edits); let text = inserted_value( &self.after[after.span.start..after.span.end], indent_at(self.source, actual.span.start), self.source, ); edits.push(( Span { start: actual.span.start, end: actual.span.start, }, text, )); } } } fn record( &self, span: Span, actual: &BTreeMap, before: Option<&BTreeMap>, after: &BTreeMap, edits: &mut LocalEdits, ) { // A typed constructor tag change retains shared fields and their trivia. if actual.len() == 1 && after.len() == 1 && let Some(before) = before.filter(|fields| fields.len() == 1) { let (old_key, old) = before.first_key_value().unwrap(); let (new_key, new) = after.first_key_value().unwrap(); if old_key != new_key && old_key.starts_with(|c: char| c.is_ascii_uppercase()) && new_key.starts_with(|c: char| c.is_ascii_uppercase()) && let Some(field) = actual.get(old_key) { let key = significant(&self.source[field.span.start..field.value.span.start])[0].span; edits.push(( Span { start: key.start + field.span.start, end: key.end + field.span.start, }, new_key.clone(), )); self.value(&field.value, Some(&old.value), &new.value, edits); return; } } for (key, field) in actual { if let Some(next) = after.get(key) { self.value( &field.value, before .and_then(|fields| fields.get(key)) .map(|field| &field.value), &next.value, edits, ); } else { erase_syntax(self.source, field.span, edits); } } let added: Vec<_> = after .iter() .filter(|(key, field)| { !actual.contains_key(*key) && before .and_then(|fields| fields.get(*key)) .is_none_or(|old| !self.same(&old.value, &field.value)) }) .collect(); if added.is_empty() { return; } let close = span.end - 1; let multiline = self.source[span.start..span.end].contains('\n'); let indent = actual .values() .min_by_key(|field| field.span.start) .map(|field| indent_at(self.source, field.span.start).to_owned()) .filter(|indent| !indent.is_empty()) .unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start))); let mut text = String::new(); for (key, field) in added { let value = inserted_value( &self.after[field.value.span.start..field.value.span.end], &indent, self.source, ); if multiline { text.push_str(&indent); text.push_str(&format!("{key} {value};{}", newline(self.source))); } else { text.push_str(&format!(" {key} {value}; ")); } } let prefix = self.source[..close].rsplit('\n').next().unwrap_or(""); let at = if multiline && prefix.chars().all(char::is_whitespace) { close - prefix.len() } else { close }; if multiline && at == close && !prefix.is_empty() { text.insert_str(0, newline(self.source)); } edits.push((Span { start: at, end: at }, text)); } fn list( &self, span: Span, actual: &[language::EditValue], before: Option<&[language::EditValue]>, after: &[language::EditValue], edits: &mut LocalEdits, ) { let previous = before.unwrap_or(&[]); let old_keys: Option> = previous .iter() .map(|item| item_key(item, self.before, self.old_aliases)) .collect(); let new_keys: Option> = after .iter() .map(|item| item_key(item, self.after, self.new_aliases)) .collect(); let keyed = old_keys.zip(new_keys).filter(|(old, new)| { old.iter().collect::>().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, edits); } return; } let mut chunks = Vec::new(); let mut start = span.start + 1; for (i, item) in actual.iter().enumerate() { let end = if let Some(next) = actual.get(i + 1) { item.span.end + significant(&self.source[item.span.end..next.span.start])[0] .span .start } else { span.end - 1 }; chunks.push(Span { start, end }); start = end + 1; } let multiline = self.source[span.start..span.end].contains('\n'); let indent = actual .first() .map(|item| indent_at(self.source, item.span.start).to_owned()) .unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start))); let mut text = String::new(); let mut retained = BTreeSet::new(); for (i, next) in after.iter().enumerate() { let suffix; if let Some(index) = mapping[i].filter(|index| *index < actual.len()) { retained.insert(index); let current = &actual[index]; let chunk = chunks[index]; text.push_str(&self.source[chunk.start..current.span.start]); let mut local = Vec::new(); self.value(current, previous.get(index), next, &mut local); text.push_str(&changed_text(self.source, current.span, local)); suffix = &self.source[current.span.end..chunk.end]; } else { if multiline { text.push_str(newline(self.source)); text.push_str(&indent); } else { text.push(' '); } text.push_str(&inserted_value( &self.after[next.span.start..next.span.end], &indent, self.source, )); suffix = ""; } if i + 1 < after.len() { text.push(','); } text.push_str(suffix); } for (i, chunk) in chunks .iter() .enumerate() .filter(|(i, _)| !retained.contains(i)) { let _ = i; text.push_str(&trivia(&self.source[chunk.start..chunk.end])); } if actual.is_empty() { text.push_str(&self.source[span.start + 1..span.end - 1]); } edits.push(( Span { start: span.start + 1, end: span.end - 1, }, text, )); } } fn item_key( value: &language::EditValue, source: &str, aliases: &BTreeMap, ) -> Option { use language::EditKind; match &value.kind { EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")), EditKind::Atom => Some(format!( "literal:{}", &source[value.span.start..value.span.end] )), EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| { fields.get(key).and_then(|field| { if let EditKind::Reference(alias) = &field.value.kind { aliases.get(alias).map(|id| format!("{key}:{id}")) } else { None } }) }), EditKind::List(_) => None, } } 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.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) }