Files
ARCADIA-Studio/crates/oa-core/src/source_project.rs
T

1609 lines
55 KiB
Rust

//! 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<LogicalPath, String>,
}
fn unique_files<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<LogicalPath, String>, D::Error> {
struct Files;
impl<'de> serde::de::Visitor<'de> for Files {
type Value = BTreeMap<LogicalPath, String>;
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<A: serde::de::MapAccess<'de>>(
self,
mut access: A,
) -> Result<Self::Value, A::Error> {
let mut files = BTreeMap::new();
while let Some((path, source)) = access.next_entry::<String, String>()? {
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<SourceLocation>,
}
#[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<Id, SourceLocation>,
/// Complete value spans for top-level declaration fields.
pub fields: BTreeMap<(Id, String), SourceLocation>,
pub includes: Vec<IncludeLocation>,
pub project_header: SourceLocation,
pub project_body: SourceLocation,
/// The complete `description ...;` field, when explicitly present.
pub project_description: Option<SourceLocation>,
}
#[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<SourceDiagnostic>,
}
/// 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<LogicalPath, String> {
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<String>,
) -> 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<String>,
) -> 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<IncludeLocation>, Vec<SourceDiagnostic>> {
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<LogicalPath, Vec<(Span, String)>>;
/// 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<CompiledSourceProject, Vec<SourceDiagnostic>> {
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<SourceDiagnostic>> {
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<SourceLocation>),
Leave,
}
let mut stack = vec![Visit::Enter(bundle.entry.clone(), 0, None)];
let mut active: Vec<LogicalPath> = 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<String, SourceLocation> = 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::<Vec<_>>()
.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<SourceDiagnostic> {
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<SourceDiagnostic>> {
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<LogicalPath, String> = 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<CompiledSourceProject, Vec<SourceDiagnostic>> {
// 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(&current.bundle, limits)?;
if checked.index != current.index
|| checked.decoded.project != current.decoded.project
|| checked.bundle != current.bundle
{
return Err(vec![problem(
&current.bundle,
&current.bundle.entry,
Span::default(),
"stale_source_index",
"Compile the current documents before applying a model update.",
)]);
}
let errors: Vec<_> = model_diagnostics(&current.bundle, &current.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(
&current.bundle,
&current.bundle.entry,
Span::default(),
"model_limit",
message,
)]
})?;
let old = &current.decoded.project;
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let old_aliases: BTreeMap<_, _> = old
.identities()
.into_iter()
.map(|(id, alias)| (alias.to_owned(), id))
.collect();
let new_aliases: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, alias)| (alias.to_owned(), id))
.collect();
let mut bundle = current.bundle.clone();
let mut edits: Edits = BTreeMap::new();
for (id, old_text) in &before {
let location = &current.index.declarations[id];
if after.get(id) != Some(old_text) {
let 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 = &current.index.project_header;
let source = &bundle.files[&location.path][location.span.start..location.span.end];
let before = project_header(old);
let after = project_header(project);
let parse = |text| {
language::edit_header(text).map_err(|error| {
vec![problem(
&bundle,
&location.path,
location.span,
"source_edit",
error.message,
)]
})
};
let update = SyntaxUpdate {
source,
before: &before,
after: &after,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
let mut local = Vec::new();
update.header(
&parse(source)?,
&parse(&before)?,
&parse(&after)?,
&mut local,
);
edits
.entry(location.path.clone())
.or_default()
.extend(local.into_iter().map(|(span, text)| {
(
Span {
start: span.start + location.span.start,
end: span.end + location.span.start,
},
text,
)
}));
}
if old.description != project.description {
let field = format!("description {};", quote(&project.description));
if let Some(location) = &current.index.project_description {
let value =
significant(&bundle.files[&location.path][location.span.start..location.span.end])
[1]
.span;
edits.entry(location.path.clone()).or_default().push((
Span {
start: location.span.start + value.start,
end: location.span.start + value.end,
},
quote(&project.description),
));
} else {
let location = &current.index.project_body;
edits.entry(location.path.clone()).or_default().push((
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<LogicalPath, String> = 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::TokenSpan> {
language::tokenize(source)
.into_iter()
.filter(|token| {
!matches!(
token.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.collect()
}
type LocalEdits = Vec<(Span, String)>;
fn erase_syntax(source: &str, span: Span, edits: &mut LocalEdits) {
edits.extend(
significant(&source[span.start..span.end])
.into_iter()
.map(|token| {
(
Span {
start: span.start + token.span.start,
end: span.start + token.span.end,
},
String::new(),
)
}),
);
}
fn trivia(source: &str) -> String {
language::tokenize(source)
.into_iter()
.filter(|token| {
matches!(
token.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|token| &source[token.span.start..token.span.end])
.collect()
}
fn changed_text(source: &str, span: Span, mut edits: LocalEdits) -> String {
let mut result = source[span.start..span.end].to_owned();
edits.sort_by_key(|(span, _)| (span.start, span.end));
for (edit, text) in edits.into_iter().rev() {
result.replace_range(edit.start - span.start..edit.end - span.start, &text);
}
result
}
fn newline(source: &str) -> &'static str {
if source.contains("\r\n") {
"\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<String, Id>,
new_aliases: &'a BTreeMap<String, Id>,
}
impl SyntaxUpdate<'_> {
fn header(
&self,
actual: &BTreeMap<&str, Span>,
before: &BTreeMap<&str, Span>,
after: &BTreeMap<&str, Span>,
edits: &mut LocalEdits,
) {
for (key, span) in actual {
match after.get(key) {
Some(next)
if before.get(key).is_none_or(|old| {
self.before[old.start..old.end] != self.after[next.start..next.end]
}) =>
{
edits.push((*span, self.after[next.start..next.end].to_owned()))
}
None => erase_syntax(self.source, *span, edits),
_ => {}
}
}
if let Some(name) = after.get("name")
&& !actual.contains_key("name")
{
let at = actual["id_keyword"].start;
edits.push((
Span { start: at, end: at },
format!("{} ", &self.after[name.start..name.end]),
));
}
}
fn same(&self, before: &language::EditValue, after: &language::EditValue) -> bool {
use language::EditKind;
match (&before.kind, &after.kind) {
(EditKind::Record(a), EditKind::Record(b)) => {
a.len() == b.len()
&& a.iter().all(|(key, value)| {
b.get(key)
.is_some_and(|other| self.same(&value.value, &other.value))
})
}
(EditKind::List(a), EditKind::List(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| self.same(a, b))
}
_ => {
self.before[before.span.start..before.span.end]
== self.after[after.span.start..after.span.end]
}
}
}
fn value(
&self,
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
edits: &mut LocalEdits,
) {
use language::EditKind;
if before.is_some_and(|before| self.same(before, after)) {
return;
}
match (&actual.kind, &after.kind) {
(EditKind::Record(fields), EditKind::Record(next)) => {
let previous = before.and_then(|old| {
if let EditKind::Record(fields) = &old.kind {
Some(fields)
} else {
None
}
});
self.record(actual.span, fields, previous, next, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
if let EditKind::List(items) = &old.kind {
Some(items.as_slice())
} else {
None
}
});
self.list(actual.span, items, previous, next, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
.pop()
.expect("reference alias");
edits.push((
Span {
start: actual.span.start + token.span.start,
end: actual.span.start + token.span.end,
},
alias.clone(),
));
}
(EditKind::Atom, EditKind::Atom) => edits.push((
actual.span,
self.after[after.span.start..after.span.end].into(),
)),
_ => {
erase_syntax(self.source, actual.span, edits);
let text = inserted_value(
&self.after[after.span.start..after.span.end],
indent_at(self.source, actual.span.start),
self.source,
);
edits.push((
Span {
start: actual.span.start,
end: actual.span.start,
},
text,
));
}
}
}
fn record(
&self,
span: Span,
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
if actual.len() == 1
&& after.len() == 1
&& let Some(before) = before.filter(|fields| fields.len() == 1)
{
let (old_key, old) = before.first_key_value().unwrap();
let (new_key, new) = after.first_key_value().unwrap();
if old_key != new_key
&& old_key.starts_with(|c: char| c.is_ascii_uppercase())
&& new_key.starts_with(|c: char| c.is_ascii_uppercase())
&& let Some(field) = actual.get(old_key)
{
let key =
significant(&self.source[field.span.start..field.value.span.start])[0].span;
edits.push((
Span {
start: key.start + field.span.start,
end: key.end + field.span.start,
},
new_key.clone(),
));
self.value(&field.value, Some(&old.value), &new.value, edits);
return;
}
}
for (key, field) in actual {
if let Some(next) = after.get(key) {
self.value(
&field.value,
before
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
edits,
);
} else {
erase_syntax(self.source, field.span, edits);
}
}
let added: Vec<_> = after
.iter()
.filter(|(key, field)| {
!actual.contains_key(*key)
&& before
.and_then(|fields| fields.get(*key))
.is_none_or(|old| !self.same(&old.value, &field.value))
})
.collect();
if added.is_empty() {
return;
}
let close = span.end - 1;
let multiline = self.source[span.start..span.end].contains('\n');
let indent = actual
.values()
.min_by_key(|field| field.span.start)
.map(|field| indent_at(self.source, field.span.start).to_owned())
.filter(|indent| !indent.is_empty())
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
let mut text = String::new();
for (key, field) in added {
let value = inserted_value(
&self.after[field.value.span.start..field.value.span.end],
&indent,
self.source,
);
if multiline {
text.push_str(&indent);
text.push_str(&format!("{key} {value};{}", newline(self.source)));
} else {
text.push_str(&format!(" {key} {value}; "));
}
}
let prefix = self.source[..close].rsplit('\n').next().unwrap_or("");
let at = if multiline && prefix.chars().all(char::is_whitespace) {
close - prefix.len()
} else {
close
};
if multiline && at == close && !prefix.is_empty() {
text.insert_str(0, newline(self.source));
}
edits.push((Span { start: at, end: at }, text));
}
fn list(
&self,
span: Span,
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous
.iter()
.map(|item| item_key(item, self.before, self.old_aliases))
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| item_key(item, self.after, self.new_aliases))
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
&& new.iter().collect::<BTreeSet<_>>().len() == new.len()
});
let mapping: Vec<_> = if let Some((old, new)) = keyed {
let positions: BTreeMap<_, _> = old
.into_iter()
.enumerate()
.map(|(i, key)| (key, i))
.collect();
new.into_iter()
.map(|key| positions.get(&key).copied())
.collect()
} else {
(0..after.len())
.map(|i| (i < actual.len()).then_some(i))
.collect()
};
if actual.len() == after.len()
&& mapping
.iter()
.enumerate()
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, edits);
}
return;
}
let mut chunks = Vec::new();
let mut start = span.start + 1;
for (i, item) in actual.iter().enumerate() {
let end = if let Some(next) = actual.get(i + 1) {
item.span.end
+ significant(&self.source[item.span.end..next.span.start])[0]
.span
.start
} else {
span.end - 1
};
chunks.push(Span { start, end });
start = end + 1;
}
let multiline = self.source[span.start..span.end].contains('\n');
let indent = actual
.first()
.map(|item| indent_at(self.source, item.span.start).to_owned())
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
let mut text = String::new();
let mut retained = BTreeSet::new();
for (i, next) in after.iter().enumerate() {
let suffix;
if let Some(index) = mapping[i].filter(|index| *index < actual.len()) {
retained.insert(index);
let current = &actual[index];
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(current, previous.get(index), next, &mut local);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
if multiline {
text.push_str(newline(self.source));
text.push_str(&indent);
} else {
text.push(' ');
}
text.push_str(&inserted_value(
&self.after[next.span.start..next.span.end],
&indent,
self.source,
));
suffix = "";
}
if i + 1 < after.len() {
text.push(',');
}
text.push_str(suffix);
}
for (i, chunk) in chunks
.iter()
.enumerate()
.filter(|(i, _)| !retained.contains(i))
{
let _ = i;
text.push_str(&trivia(&self.source[chunk.start..chunk.end]));
}
if actual.is_empty() {
text.push_str(&self.source[span.start + 1..span.end - 1]);
}
edits.push((
Span {
start: span.start + 1,
end: span.end - 1,
},
text,
));
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, Id>,
) -> Option<String> {
use language::EditKind;
match &value.kind {
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
EditKind::Atom => Some(format!(
"literal:{}",
&source[value.span.start..value.span.end]
)),
EditKind::Record(fields) => ["field", "referenced", "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<Id> {
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<String>) -> 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<Id, LogicalPath> {
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<Id, &str>) -> (u8, Vec<String>) {
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)
}