1932 lines
68 KiB
Rust
1932 lines
68 KiB
Rust
//! 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<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>,
|
|
/// Interior of each declaration body, excluding its opening/closing braces.
|
|
pub declaration_bodies: BTreeMap<Id, SourceLocation>,
|
|
/// Authored element nesting, distinct from an explicit semantic parent field.
|
|
/// Flat declarations have no entry; the project is not a lexical element owner.
|
|
pub lexical_owners: BTreeMap<Id, Id>,
|
|
/// Complete value spans for top-level declaration fields.
|
|
pub fields: BTreeMap<(Id, String), SourceLocation>,
|
|
pub includes: Vec<IncludeLocation>,
|
|
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)>>;
|
|
|
|
/// 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<SourceDiagnostic> {
|
|
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<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
|
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<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 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::<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);
|
|
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<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 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, language::DeclarationFormat::Compact);
|
|
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 {SCHEMA_VERSION};\n{}{{\n", project_header(project));
|
|
if !project.description.is_empty() {
|
|
entry.push_str(&format!(
|
|
" description {};\n",
|
|
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>> {
|
|
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<Id, Id>,
|
|
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(¤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::<BTreeSet<_>>().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();
|
|
// Comparison must include defaults even when persistence serialization or
|
|
// compact output omits them. Otherwise an unrelated payload edit erases
|
|
// unchanged explicit fields from the authored document.
|
|
let before: BTreeMap<_, _> =
|
|
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
|
|
.into_iter()
|
|
.collect();
|
|
let after: BTreeMap<_, _> =
|
|
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
|
.into_iter()
|
|
.collect();
|
|
let compact: BTreeMap<_, _> =
|
|
language::format_declarations(project, language::DeclarationFormat::Compact)
|
|
.into_iter()
|
|
.collect();
|
|
let compact_values: BTreeMap<_, _> =
|
|
language::format_declarations(project, language::DeclarationFormat::CompactValues)
|
|
.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)?;
|
|
let next_id = identities[&id];
|
|
let value_text = &compact_values[&next_id];
|
|
let insertion = InsertionSyntax::new(
|
|
&next.fields,
|
|
&parse(value_text)?.fields,
|
|
value_text,
|
|
&parse(&compact[&next_id])?.fields,
|
|
);
|
|
let old_kind = previous.header["kind"];
|
|
let new_kind = next.header["kind"];
|
|
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
|
|
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
|
|
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
|
|
{
|
|
// An implicit value is supplied by its declaration context.
|
|
// After a kind change, an unchanged value may need explicit
|
|
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
|
|
// Reconsider only absent fields; authored explicit values still
|
|
// compare against their complete old values and keep trivia.
|
|
previous_fields.retain(|key, _| actual_fields.contains_key(key));
|
|
}
|
|
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,
|
|
insertion: Some(&insertion),
|
|
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,
|
|
insertion: None,
|
|
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,
|
|
&compact,
|
|
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::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
|
|
}
|
|
|
|
// Value fragments retain required payload fields even when the enclosing
|
|
// declaration field is itself omittable. For example, a selected String type
|
|
// needs minimum_length; an Event Signal needs items. The complete tree remains
|
|
// the comparison authority; this parallel tree is only an insertion template.
|
|
struct InsertionSyntax<'a> {
|
|
source: &'a str,
|
|
values: BTreeMap<usize, Span>,
|
|
omitted_fields: BTreeSet<usize>,
|
|
}
|
|
impl<'a> InsertionSyntax<'a> {
|
|
fn new(
|
|
complete: &language::EditValue,
|
|
compact_values: &language::EditValue,
|
|
source: &'a str,
|
|
compact: &language::EditValue,
|
|
) -> Self {
|
|
let mut result = Self {
|
|
source,
|
|
values: BTreeMap::new(),
|
|
omitted_fields: BTreeSet::new(),
|
|
};
|
|
result.collect(complete, compact_values);
|
|
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
|
|
(&complete.kind, &compact.kind)
|
|
{
|
|
for (key, field) in all {
|
|
if !kept.contains_key(key) {
|
|
result.omitted_fields.insert(field.value.span.start);
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
|
|
// Borrow the existing compact text once. Copying each containing record
|
|
// here would duplicate a large nested payload at every depth.
|
|
self.values.insert(complete.span.start, compact.span);
|
|
match (&complete.kind, &compact.kind) {
|
|
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
|
|
for (key, field) in all {
|
|
if let Some(next) = kept.get(key) {
|
|
self.collect(&field.value, &next.value);
|
|
} else {
|
|
self.omitted_fields.insert(field.value.span.start);
|
|
}
|
|
}
|
|
}
|
|
(language::EditKind::List(all), language::EditKind::List(kept)) => {
|
|
assert_eq!(
|
|
all.len(),
|
|
kept.len(),
|
|
"compaction preserves array membership"
|
|
);
|
|
for (field, next) in all.iter().zip(kept) {
|
|
self.collect(field, next);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
insertion: Option<&'a InsertionSyntax<'a>>,
|
|
old_aliases: &'a BTreeMap<String, String>,
|
|
new_aliases: &'a BTreeMap<String, String>,
|
|
// 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<Id, Id>>,
|
|
}
|
|
impl SyntaxUpdate<'_> {
|
|
fn inserted(&self, value: &language::EditValue) -> &str {
|
|
self.insertion
|
|
.and_then(|syntax| {
|
|
syntax
|
|
.values
|
|
.get(&value.span.start)
|
|
.map(|span| &syntax.source[span.start..span.end])
|
|
})
|
|
.unwrap_or(&self.after[value.span.start..value.span.end])
|
|
}
|
|
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.inserted(after),
|
|
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>,
|
|
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)
|
|
&& self.insertion.is_none_or(|syntax| {
|
|
!syntax.omitted_fields.contains(&field.value.span.start)
|
|
})
|
|
&& 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.inserted(&field.value), &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<Vec<_>> = previous
|
|
.iter()
|
|
.map(|item| {
|
|
item_key(
|
|
item,
|
|
self.before,
|
|
self.old_aliases,
|
|
self.legacy_value_paths,
|
|
None,
|
|
context,
|
|
)
|
|
})
|
|
.collect();
|
|
let new_keys: Option<Vec<_>> = 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::<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, 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.inserted(next), &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<String, String>,
|
|
context: EditContext,
|
|
) -> Option<String> {
|
|
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::<Option<Vec<_>>>()?
|
|
}
|
|
};
|
|
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn item_key(
|
|
value: &language::EditValue,
|
|
source: &str,
|
|
aliases: &BTreeMap<String, String>,
|
|
legacy_value_paths: bool,
|
|
origins: Option<&BTreeMap<Id, Id>>,
|
|
context: EditContext,
|
|
) -> Option<String> {
|
|
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::<Option<Vec<_>>>()
|
|
} 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<Id> {
|
|
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<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)
|
|
}
|