2867 lines
105 KiB
Rust
2867 lines
105 KiB
Rust
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
|
#[path = "language/semantic_source.rs"]
|
|
mod semantic_source;
|
|
#[path = "migration_v2/source.rs"]
|
|
mod v2_source;
|
|
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
|
|
use crate::source_defaults::{self, Context as DefaultContext};
|
|
use crate::*;
|
|
use serde_json::{Map, Value};
|
|
use std::{collections::BTreeMap, fmt};
|
|
/// A located syntax or engineering diagnostic. Positions are one-based and
|
|
/// columns count Unicode scalars.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Diagnostic {
|
|
pub severity: validation::Severity,
|
|
pub code: String,
|
|
pub object_id: Option<Id>,
|
|
pub field: Option<String>,
|
|
pub line: usize,
|
|
pub column: usize,
|
|
pub message: String,
|
|
pub span: Span,
|
|
}
|
|
|
|
/// Admission budgets applied before structural validation. Limits are caller
|
|
/// policy, not a restriction on the engineering language's semantics.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ParseLimits {
|
|
pub max_source_bytes: usize,
|
|
pub max_graph_objects: usize,
|
|
pub max_records: usize,
|
|
pub max_step_references: usize,
|
|
}
|
|
|
|
impl ParseLimits {
|
|
pub const UNLIMITED: Self = Self {
|
|
max_source_bytes: usize::MAX,
|
|
max_graph_objects: usize::MAX,
|
|
max_records: usize::MAX,
|
|
max_step_references: usize::MAX,
|
|
};
|
|
|
|
pub(crate) fn check_counts(
|
|
self,
|
|
graph: usize,
|
|
records: usize,
|
|
steps: usize,
|
|
) -> Result<(), String> {
|
|
if graph > self.max_graph_objects
|
|
|| records > self.max_records
|
|
|| steps > self.max_step_references
|
|
{
|
|
return Err(format!(
|
|
"Model exceeds admission limits ({} graph objects, {} total records, {} ordered references).",
|
|
self.max_graph_objects, self.max_records, self.max_step_references
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn check_model(self, project: &Project) -> Result<(), String> {
|
|
let graph = project
|
|
.elements
|
|
.len()
|
|
.saturating_add(project.relations.len());
|
|
let records = graph
|
|
.saturating_add(1)
|
|
.saturating_add(project.diagrams.len())
|
|
.saturating_add(
|
|
project
|
|
.diagrams
|
|
.values()
|
|
.map(|d| {
|
|
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
|
|
d.nodes
|
|
.values()
|
|
.map(|n| n.port_anchors.len())
|
|
.sum::<usize>(),
|
|
)
|
|
})
|
|
.sum::<usize>(),
|
|
)
|
|
.saturating_add(project.artifact_links.len())
|
|
.saturating_add(project.traces.len());
|
|
let steps = project
|
|
.elements
|
|
.values()
|
|
.map(|e| e.steps().map_or(0, Vec::len))
|
|
.sum::<usize>()
|
|
.saturating_add(
|
|
project
|
|
.relations
|
|
.values()
|
|
.map(|r| match &r.body {
|
|
RelationBody::ContextualLink { source, target, .. } => source
|
|
.references
|
|
.len()
|
|
.saturating_add(target.references.len()),
|
|
_ => 0,
|
|
})
|
|
.sum::<usize>(),
|
|
);
|
|
self.check_counts(graph, records, steps)
|
|
}
|
|
}
|
|
|
|
/// Half-open UTF-8 byte offsets into the exact analyzed source buffer.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub struct Span {
|
|
pub start: usize,
|
|
pub end: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TokenClass {
|
|
Keyword,
|
|
Identifier,
|
|
String,
|
|
Number,
|
|
Comment,
|
|
Punctuation,
|
|
Whitespace,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct TokenSpan {
|
|
pub class: TokenClass,
|
|
pub span: Span,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeclarationSpan {
|
|
pub alias: String,
|
|
pub id: Option<Id>,
|
|
pub kind: String,
|
|
pub span: Span,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Analysis {
|
|
pub tokens: Vec<TokenSpan>,
|
|
pub diagnostics: Vec<Diagnostic>,
|
|
pub declarations: Vec<DeclarationSpan>,
|
|
}
|
|
|
|
/// Check the decoded spelling required by declaration-header UUID clauses.
|
|
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
|
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
|
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
|
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
|
decoded.len() == 36
|
|
&& [8, 13, 18, 23]
|
|
.into_iter()
|
|
.all(|index| decoded.as_bytes()[index] == b'-')
|
|
}
|
|
|
|
/// Budgets for one original source buffer. Policy is caller-owned; these
|
|
/// limits do not change the grammar or perform model admission.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OutlineLimits {
|
|
pub max_source_bytes: usize,
|
|
pub max_tokens: usize,
|
|
pub max_declarations: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum OutlineResource {
|
|
SourceBytes,
|
|
Tokens,
|
|
Declarations,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OutlineLimit {
|
|
pub resource: OutlineResource,
|
|
pub limit: usize,
|
|
pub observed_at_least: usize,
|
|
/// Original byte offset where admission stopped; never a rewritten offset.
|
|
pub offset: usize,
|
|
}
|
|
|
|
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
|
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OutlineDeclaration {
|
|
pub declaration: DeclarationSpan,
|
|
pub kind_span: Span,
|
|
pub alias_span: Span,
|
|
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
|
/// case declaration.id is None. Missing/non-string ID values have no span.
|
|
pub id_literal_span: Option<Span>,
|
|
}
|
|
|
|
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
|
/// text remains available. Lexical errors and unmatched braces are facts only:
|
|
/// their absence does not establish syntactic or semantic validity.
|
|
///
|
|
/// A budget failure returns Err and no partial Outline. Include reachability,
|
|
/// project completeness and editor revision are intentionally not inferred here.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Outline<'a> {
|
|
pub source: &'a str,
|
|
pub tokens: Vec<TokenSpan>,
|
|
pub declarations: Vec<OutlineDeclaration>,
|
|
pub has_lexical_errors: bool,
|
|
pub has_unmatched_braces: bool,
|
|
}
|
|
|
|
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
|
/// strings and unexpected characters. No model is created or modified.
|
|
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
|
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
|
}
|
|
|
|
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
|
let mut tokens = Vec::new();
|
|
let mut cursor = 0;
|
|
while cursor < source.len() {
|
|
if tokens.len() == max_tokens {
|
|
return Err(OutlineLimit {
|
|
resource: OutlineResource::Tokens,
|
|
limit: max_tokens,
|
|
observed_at_least: tokens.len().saturating_add(1),
|
|
offset: cursor,
|
|
});
|
|
}
|
|
let start = cursor;
|
|
let ch = source[cursor..].chars().next().expect("in bounds");
|
|
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
|
cursor += 1;
|
|
while source
|
|
.as_bytes()
|
|
.get(cursor)
|
|
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
|
|
{
|
|
cursor += 1;
|
|
}
|
|
TokenClass::Whitespace
|
|
} else if source[cursor..].starts_with("//") {
|
|
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
|
|
TokenClass::Comment
|
|
} else if ch == '"' {
|
|
cursor += 1;
|
|
let mut escaped = false;
|
|
let mut closed = false;
|
|
while cursor < source.len() {
|
|
let next = source[cursor..].chars().next().expect("in bounds");
|
|
cursor += next.len_utf8();
|
|
if escaped {
|
|
escaped = false;
|
|
} else if next == '\\' {
|
|
escaped = true;
|
|
} else if next == '"' {
|
|
closed = true;
|
|
break;
|
|
}
|
|
}
|
|
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
|
|
TokenClass::String
|
|
} else {
|
|
TokenClass::Error
|
|
}
|
|
} else if ch.is_ascii_alphabetic() || ch == '_' {
|
|
cursor += 1;
|
|
while source
|
|
.as_bytes()
|
|
.get(cursor)
|
|
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
|
|
{
|
|
cursor += 1;
|
|
}
|
|
if reserved(&source[start..cursor])
|
|
|| matches!(&source[start..cursor], "true" | "false" | "none")
|
|
{
|
|
TokenClass::Keyword
|
|
} else {
|
|
TokenClass::Identifier
|
|
}
|
|
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
|
|
cursor += 1;
|
|
while source.as_bytes().get(cursor).is_some_and(|next| {
|
|
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
|
|
}) {
|
|
cursor += 1;
|
|
}
|
|
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
|
|
TokenClass::Number
|
|
} else {
|
|
TokenClass::Error
|
|
}
|
|
} else if "{}();,[]:=@".contains(ch) {
|
|
cursor += ch.len_utf8();
|
|
TokenClass::Punctuation
|
|
} else {
|
|
cursor += ch.len_utf8();
|
|
TokenClass::Error
|
|
};
|
|
tokens.push(TokenSpan {
|
|
class,
|
|
span: Span { start, end: cursor },
|
|
});
|
|
}
|
|
Ok(tokens)
|
|
}
|
|
|
|
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
|
match parse_document(source, ParseLimits::UNLIMITED) {
|
|
Ok(parsed) => parsed.diagnostics(source),
|
|
Err(diagnostics) => diagnostics,
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Diagnostic {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}:{}: {}", self.line, self.column, self.message)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct Error {
|
|
pub(crate) code: &'static str,
|
|
pub(crate) offset: usize,
|
|
pub(crate) message: String,
|
|
}
|
|
pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
|
|
let start = error.offset.min(source.len());
|
|
let prefix = &source[..start];
|
|
Diagnostic {
|
|
severity: validation::Severity::Error,
|
|
code: error.code.into(),
|
|
object_id: None,
|
|
field: None,
|
|
line: prefix.bytes().filter(|b| *b == b'\n').count() + 1,
|
|
column: prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1,
|
|
message: error.message,
|
|
span: Span {
|
|
start,
|
|
end: start + source[start..].chars().next().map_or(0, char::len_utf8),
|
|
},
|
|
}
|
|
}
|
|
fn reserved(word: &str) -> bool {
|
|
crate::reserved_alias(word)
|
|
|| declaration_kind(word).is_some()
|
|
|| matches!(word, "none" | "node" | "include")
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum DeclKind {
|
|
Element(Kind, Layer),
|
|
Relation(RelationKind),
|
|
Diagram,
|
|
Node,
|
|
Artifact,
|
|
Trace,
|
|
}
|
|
fn snake(value: &str) -> String {
|
|
let mut out = String::new();
|
|
for (i, c) in value.chars().enumerate() {
|
|
if c.is_ascii_uppercase() {
|
|
if i > 0 {
|
|
out.push('_');
|
|
}
|
|
out.push(c.to_ascii_lowercase());
|
|
} else {
|
|
out.push(c)
|
|
}
|
|
}
|
|
out
|
|
}
|
|
fn element_word(kind: Kind, layer: Layer) -> String {
|
|
let stem = match kind {
|
|
Kind::SystemActor => "actor".into(),
|
|
Kind::SystemCapability => "capability".into(),
|
|
Kind::OperationalMission => "mission".into(),
|
|
_ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()),
|
|
};
|
|
std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" })
|
|
}
|
|
fn relation_word(kind: RelationKind) -> String {
|
|
snake(serde_json::to_value(kind).unwrap().as_str().unwrap())
|
|
}
|
|
fn declaration_kind(word: &str) -> Option<DeclKind> {
|
|
static KINDS: std::sync::OnceLock<BTreeMap<String, DeclKind>> = std::sync::OnceLock::new();
|
|
KINDS
|
|
.get_or_init(|| {
|
|
let mut kinds = BTreeMap::from([
|
|
("diagram".into(), DeclKind::Diagram),
|
|
("node".into(), DeclKind::Node),
|
|
("artifact".into(), DeclKind::Artifact),
|
|
("trace".into(), DeclKind::Trace),
|
|
]);
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
for kind in Kind::ALL {
|
|
if kind.supports(layer) {
|
|
kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer));
|
|
}
|
|
}
|
|
}
|
|
for kind in RelationKind::ALL {
|
|
kinds.insert(relation_word(kind), DeclKind::Relation(kind));
|
|
}
|
|
kinds
|
|
})
|
|
.get(word)
|
|
.copied()
|
|
}
|
|
fn quote(value: &str) -> String {
|
|
serde_json::to_string(value).expect("string serialization")
|
|
}
|
|
fn reference_field(key: &str) -> bool {
|
|
matches!(
|
|
key,
|
|
"parent"
|
|
| "steps"
|
|
| "occurrences"
|
|
| "branches"
|
|
| "Named"
|
|
| "items"
|
|
| "incoming_items"
|
|
| "outgoing_items"
|
|
| "triggers"
|
|
| "effects"
|
|
| "interface"
|
|
| "context"
|
|
| "represented"
|
|
| "exchange"
|
|
| "sequence"
|
|
| "scenario"
|
|
| "reply_to"
|
|
| "referenced"
|
|
| "local"
|
|
| "field"
|
|
| "path"
|
|
| "references"
|
|
| "occurrence"
|
|
| "allocation"
|
|
| "work"
|
|
| "supertype"
|
|
| "sender"
|
|
| "receiver"
|
|
| "action"
|
|
| "lifeline"
|
|
| "state"
|
|
| "constrained"
|
|
| "exchanges"
|
|
| "hidden_edges"
|
|
| "Any"
|
|
| "source"
|
|
| "target"
|
|
| "source_port"
|
|
| "target_port"
|
|
| "element"
|
|
| "owner"
|
|
| "oa"
|
|
| "sa"
|
|
| "diagram"
|
|
| "edge"
|
|
| "port"
|
|
| "machine"
|
|
)
|
|
}
|
|
fn enum_field(key: &str) -> bool {
|
|
matches!(
|
|
key,
|
|
"kind"
|
|
| "direction"
|
|
| "orientation"
|
|
| "contract"
|
|
| "layer"
|
|
| "projection"
|
|
| "relation"
|
|
| "phase"
|
|
| "operator"
|
|
| "definition"
|
|
| "type_ref"
|
|
| "viewpoint"
|
|
| "message_kind"
|
|
| "transition_kind"
|
|
| "side"
|
|
| "mechanism"
|
|
| "storage"
|
|
| "transmission"
|
|
| "acquisition"
|
|
)
|
|
}
|
|
|
|
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
|
|
/// adopts a model and never overwrites an editor draft.
|
|
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
|
parse_limited(source, ParseLimits::UNLIMITED)
|
|
}
|
|
|
|
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
|
|
parse_with_report_limited(source, limits).map(|decoded| decoded.project)
|
|
}
|
|
pub fn parse_with_report(
|
|
source: &str,
|
|
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
|
parse_with_report_limited(source, ParseLimits::UNLIMITED)
|
|
}
|
|
pub fn parse_with_report_limited(
|
|
source: &str,
|
|
limits: ParseLimits,
|
|
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
|
let parsed = parse_document(source, limits)?;
|
|
let errors: Vec<_> = parsed
|
|
.diagnostics(source)
|
|
.into_iter()
|
|
.filter(|d| d.severity == validation::Severity::Error)
|
|
.collect();
|
|
if errors.is_empty() {
|
|
Ok(crate::persistence::DecodeResult {
|
|
project: parsed.project,
|
|
migration: parsed.migration,
|
|
})
|
|
} else {
|
|
Err(errors)
|
|
}
|
|
}
|
|
|
|
struct ParsedDocument {
|
|
project: Project,
|
|
migration: Option<crate::persistence::MigrationReport>,
|
|
locations: BTreeMap<Id, usize>,
|
|
fields: BTreeMap<(Id, String), usize>,
|
|
}
|
|
impl ParsedDocument {
|
|
fn diagnostics(&self, source: &str) -> Vec<Diagnostic> {
|
|
// Semantic analysis can report thousands of warnings in a large draft.
|
|
// Index line starts once rather than rescanning the entire source prefix
|
|
// for every issue. Columns still count scalars, never UTF-8 bytes.
|
|
let line_starts: Vec<_> = std::iter::once(0)
|
|
.chain(source.match_indices('\n').map(|(offset, _)| offset + 1))
|
|
.collect();
|
|
validation::validate(&self.project)
|
|
.into_iter()
|
|
.map(|issue| {
|
|
let offset = issue
|
|
.element
|
|
.and_then(|id| {
|
|
issue
|
|
.field
|
|
.as_ref()
|
|
.and_then(|field| self.fields.get(&(id, field.clone())).copied())
|
|
.or_else(|| self.locations.get(&id).copied())
|
|
})
|
|
.unwrap_or(0);
|
|
let line = line_starts.partition_point(|start| *start <= offset);
|
|
Diagnostic {
|
|
severity: issue.severity,
|
|
code: issue.code,
|
|
object_id: issue.element,
|
|
field: issue.field,
|
|
line,
|
|
column: source[line_starts[line - 1]..offset].chars().count() + 1,
|
|
message: issue.message,
|
|
span: Span {
|
|
start: offset,
|
|
end: offset + source[offset..].chars().next().map_or(0, char::len_utf8),
|
|
},
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, Vec<Diagnostic>> {
|
|
if source.len() > limits.max_source_bytes {
|
|
let mut result = diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: 0,
|
|
message: format!("Source exceeds {} bytes.", limits.max_source_bytes),
|
|
},
|
|
);
|
|
result.code = "source_limit".into();
|
|
return Err(vec![result]);
|
|
}
|
|
let tokens = strict_tokens(source).map_err(|e| vec![diagnostic(source, e)])?;
|
|
if tokens.get(1).is_some_and(|t| t.text(source) == "1") {
|
|
let old = legacy::parse_limited(source, limits).map_err(|errors| {
|
|
errors
|
|
.into_iter()
|
|
.map(|e| {
|
|
let start = offset_for(source, e.line, e.column);
|
|
diagnostic(
|
|
source,
|
|
Error {
|
|
code: e.code,
|
|
offset: start,
|
|
message: e.message,
|
|
},
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})?;
|
|
let decoded =
|
|
crate::persistence::migrate_v1_with_report_limited(old, limits).map_err(|error| {
|
|
vec![diagnostic(
|
|
source,
|
|
Error {
|
|
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
|
"model_limit"
|
|
} else {
|
|
"invalid_source"
|
|
},
|
|
offset: 0,
|
|
message: error.to_string(),
|
|
},
|
|
)]
|
|
})?;
|
|
return Ok(ParsedDocument {
|
|
project: decoded.project,
|
|
migration: decoded.migration,
|
|
locations: BTreeMap::new(),
|
|
fields: BTreeMap::new(),
|
|
});
|
|
}
|
|
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
|
|
return v2_source::parse(source, tokens, limits);
|
|
}
|
|
Parser {
|
|
source,
|
|
tokens,
|
|
cursor: 0,
|
|
value_depth: 0,
|
|
limits,
|
|
}
|
|
.document()
|
|
.map_err(|e| vec![diagnostic(source, e)])
|
|
}
|
|
fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
|
let mut offset = 0;
|
|
for (i, part) in source.split_inclusive('\n').enumerate() {
|
|
if i + 1 == line {
|
|
return offset
|
|
+ part
|
|
.char_indices()
|
|
.nth(column.saturating_sub(1))
|
|
.map_or(part.len(), |(n, _)| n);
|
|
}
|
|
offset += part.len();
|
|
}
|
|
source.len()
|
|
}
|
|
|
|
/// Canonical output uses persistent aliases and explicit identities, omitting
|
|
/// only values supplied by source defaults. Typed nested records are ordinary
|
|
/// language fields; JSON documents are not embedded.
|
|
pub fn format(project: &Project) -> String {
|
|
let mut out = std::format!(
|
|
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
|
|
project.alias,
|
|
quote(&project.name),
|
|
project.id,
|
|
);
|
|
if !project.description.is_empty() {
|
|
out.push_str(&std::format!(
|
|
" description {};\n",
|
|
quote(&project.description)
|
|
));
|
|
}
|
|
out.push('\n');
|
|
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
|
|
out.push_str(&declaration);
|
|
}
|
|
out.push_str("}\n");
|
|
out
|
|
}
|
|
|
|
/// Shared typed writer for canonical output and lossless editing. Complete edit
|
|
/// comparisons retain defaults; emitted declarations and value fragments apply
|
|
/// only the omission rules valid in their selected source context.
|
|
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
|
|
let aliases = project
|
|
.identities()
|
|
.into_iter()
|
|
.map(|(id, alias)| (id, alias.to_owned()))
|
|
.collect();
|
|
let mut declarations = Vec::new();
|
|
for e in project.elements.values() {
|
|
let mut out = String::new();
|
|
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
|
fields.insert("description".into(), Value::String(e.description.clone()));
|
|
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
|
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
|
|
write_declaration(
|
|
&mut out,
|
|
&element_word(e.kind(), e.layer()),
|
|
&e.alias,
|
|
Some(&e.name),
|
|
e.id,
|
|
&fields,
|
|
&aliases,
|
|
);
|
|
declarations.push((e.id, out));
|
|
}
|
|
for r in project.relations.values() {
|
|
let mut out = String::new();
|
|
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
|
fields.insert("description".into(), Value::String(r.description.clone()));
|
|
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
|
|
write_declaration(
|
|
&mut out,
|
|
&relation_word(r.kind()),
|
|
&r.alias,
|
|
Some(&r.name),
|
|
r.id,
|
|
&fields,
|
|
&aliases,
|
|
);
|
|
declarations.push((r.id, out));
|
|
}
|
|
for d in project.diagrams.values() {
|
|
let mut out = String::new();
|
|
let mut fields = record_fields(d);
|
|
fields.remove("nodes");
|
|
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
|
|
write_declaration(
|
|
&mut out,
|
|
"diagram",
|
|
&d.alias,
|
|
Some(&d.name),
|
|
d.id,
|
|
&fields,
|
|
&aliases,
|
|
);
|
|
declarations.push((d.id, out));
|
|
for n in d.nodes.values() {
|
|
let mut out = String::new();
|
|
let mut fields = record_fields(n);
|
|
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
|
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
|
|
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
|
declarations.push((n.id, out));
|
|
}
|
|
}
|
|
for a in project.artifact_links.values() {
|
|
let mut out = String::new();
|
|
write_declaration(
|
|
&mut out,
|
|
"artifact",
|
|
&a.alias,
|
|
Some(&a.name),
|
|
a.id,
|
|
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
|
|
&aliases,
|
|
);
|
|
declarations.push((a.id, out));
|
|
}
|
|
for t in project.traces.values() {
|
|
let mut out = String::new();
|
|
write_declaration(
|
|
&mut out,
|
|
"trace",
|
|
&t.alias,
|
|
Some(&t.name),
|
|
t.id,
|
|
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
|
|
&aliases,
|
|
);
|
|
declarations.push((t.id, out));
|
|
}
|
|
declarations
|
|
}
|
|
|
|
fn record_fields<T: serde::Serialize>(value: &T) -> Map<String, Value> {
|
|
let mut fields = serde_json::to_value(value)
|
|
.unwrap()
|
|
.as_object()
|
|
.unwrap()
|
|
.clone();
|
|
for field in ["id", "alias", "name"] {
|
|
fields.remove(field);
|
|
}
|
|
fields
|
|
}
|
|
fn payload(value: &Value) -> Map<String, Value> {
|
|
match value {
|
|
Value::Object(m)
|
|
if m.len() == 1
|
|
&& m.keys()
|
|
.next()
|
|
.unwrap()
|
|
.chars()
|
|
.next()
|
|
.unwrap()
|
|
.is_ascii_uppercase() =>
|
|
{
|
|
payload(m.values().next().unwrap())
|
|
}
|
|
Value::Object(m) => m.clone(),
|
|
Value::String(_) => Map::new(),
|
|
_ => unreachable!("typed enum body"),
|
|
}
|
|
}
|
|
fn replace_payload(value: &mut Value, fields: Map<String, Value>) {
|
|
match value {
|
|
Value::Object(m)
|
|
if m.len() == 1
|
|
&& m.keys()
|
|
.next()
|
|
.unwrap()
|
|
.chars()
|
|
.next()
|
|
.unwrap()
|
|
.is_ascii_uppercase() =>
|
|
{
|
|
replace_payload(m.values_mut().next().unwrap(), fields)
|
|
}
|
|
Value::Object(m) => *m = fields,
|
|
Value::String(_) => debug_assert!(fields.is_empty()),
|
|
_ => unreachable!("typed enum body"),
|
|
}
|
|
}
|
|
fn write_declaration(
|
|
out: &mut String,
|
|
kind: &str,
|
|
alias: &str,
|
|
name: Option<&str>,
|
|
id: Id,
|
|
fields: &Map<String, Value>,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
out.push_str(&std::format!(" {kind} {alias}"));
|
|
if let Some(name) = name {
|
|
out.push(' ');
|
|
out.push_str("e(name));
|
|
}
|
|
out.push_str(&std::format!(" id \"{id}\" {{\n"));
|
|
write_fields(out, fields, 2, aliases);
|
|
out.push_str(" }\n\n");
|
|
}
|
|
fn write_fields(
|
|
out: &mut String,
|
|
fields: &Map<String, Value>,
|
|
indent: usize,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
for (key, value) in fields {
|
|
out.push_str(&" ".repeat(indent));
|
|
out.push_str(key);
|
|
out.push(' ');
|
|
write_value(out, value, key, indent, aliases);
|
|
out.push_str(";\n");
|
|
}
|
|
}
|
|
fn write_value(
|
|
out: &mut String,
|
|
value: &Value,
|
|
key: &str,
|
|
indent: usize,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
if key == "semantic" {
|
|
semantic_source::write(out, value, indent);
|
|
return;
|
|
}
|
|
match value {
|
|
Value::Null => out.push_str("none"),
|
|
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
|
Value::Number(v) => out.push_str(&v.to_string()),
|
|
Value::String(v) => {
|
|
if reference_field(key)
|
|
&& let Ok(id) = Id::parse_str(v)
|
|
&& let Some(alias) = aliases.get(&id)
|
|
{
|
|
out.push('@');
|
|
out.push_str(alias);
|
|
return;
|
|
}
|
|
if enum_field(key) && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
out.push_str(v)
|
|
} else {
|
|
out.push_str("e(v))
|
|
}
|
|
}
|
|
Value::Array(values) => {
|
|
out.push('[');
|
|
for (i, v) in values.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push_str(", ");
|
|
}
|
|
write_value(out, v, key, indent, aliases);
|
|
}
|
|
out.push(']');
|
|
}
|
|
Value::Object(fields) => {
|
|
out.push_str("{\n");
|
|
write_fields(out, fields, indent + 1, aliases);
|
|
out.push_str(&" ".repeat(indent));
|
|
out.push('}');
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Ast {
|
|
Literal(Value, usize),
|
|
Word(String, usize),
|
|
Reference(String, usize),
|
|
HistoricalUuid(Id, usize),
|
|
Array(Vec<Ast>, usize),
|
|
Object(BTreeMap<String, Ast>, usize),
|
|
}
|
|
impl Ast {
|
|
fn offset(&self) -> usize {
|
|
match self {
|
|
Self::Literal(_, p)
|
|
| Self::Word(_, p)
|
|
| Self::Reference(_, p)
|
|
| Self::HistoricalUuid(_, p)
|
|
| Self::Array(_, p)
|
|
| Self::Object(_, p) => *p,
|
|
}
|
|
}
|
|
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
|
|
match self {
|
|
Self::HistoricalUuid(_, _) => Err(Error {
|
|
code: "invalid_source",
|
|
offset: self.offset(),
|
|
message:
|
|
"A uuid literal is allowed only in a typed historical semantic reference slot."
|
|
.into(),
|
|
}),
|
|
Self::Word(word, offset) => {
|
|
if !enum_field(key) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires a quoted string or a typed value; '{word}' is not an enum field."
|
|
),
|
|
});
|
|
}
|
|
Ok(Value::String(word.clone()))
|
|
}
|
|
Self::Reference(alias, offset) => {
|
|
if !reference_field(key) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!("Field '{key}' does not accept an @alias reference."),
|
|
});
|
|
}
|
|
aliases
|
|
.get(alias)
|
|
.map(|id| Value::String(id.to_string()))
|
|
.ok_or_else(|| Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!("Unknown reference '@{alias}'."),
|
|
})
|
|
}
|
|
Self::Literal(value, offset) => {
|
|
if reference_field(key) && value.is_string() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires an @alias reference, not a quoted UUID or enum."
|
|
),
|
|
});
|
|
}
|
|
if enum_field(key) && value.is_string() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires an unquoted enum word or typed constructor."
|
|
),
|
|
});
|
|
}
|
|
Ok(value.clone())
|
|
}
|
|
Self::Array(items, _) => Ok(Value::Array(
|
|
items
|
|
.iter()
|
|
.map(|v| v.resolve(key, aliases))
|
|
.collect::<Result<_, _>>()?,
|
|
)),
|
|
Self::Object(fields, _) => Ok(Value::Object(
|
|
fields
|
|
.iter()
|
|
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
|
|
.collect::<Result<_, Error>>()?,
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
struct Lexeme {
|
|
class: TokenClass,
|
|
span: Span,
|
|
}
|
|
impl Lexeme {
|
|
fn text<'a>(&self, source: &'a str) -> &'a str {
|
|
&source[self.span.start..self.span.end]
|
|
}
|
|
}
|
|
fn strict_tokens(source: &str) -> Result<Vec<Lexeme>, Error> {
|
|
let mut out = Vec::new();
|
|
for t in tokenize(source) {
|
|
if t.class == TokenClass::Error {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: t.span.start,
|
|
message: if source[t.span.start..].starts_with('"') {
|
|
"Invalid or unterminated quoted string.".into()
|
|
} else {
|
|
std::format!("Invalid token '{}'.", &source[t.span.start..t.span.end])
|
|
},
|
|
});
|
|
}
|
|
if !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment) {
|
|
out.push(Lexeme {
|
|
class: t.class,
|
|
span: t.span,
|
|
});
|
|
}
|
|
}
|
|
out.push(Lexeme {
|
|
class: TokenClass::Punctuation,
|
|
span: Span {
|
|
start: source.len(),
|
|
end: source.len(),
|
|
},
|
|
});
|
|
Ok(out)
|
|
}
|
|
pub(crate) struct Declaration {
|
|
kind: DeclKind,
|
|
pub(crate) alias: String,
|
|
name: String,
|
|
pub(crate) id: Id,
|
|
fields: BTreeMap<String, Ast>,
|
|
offset: usize,
|
|
pub(crate) span: Span,
|
|
/// The containing element's identity, independent of an authored parent field.
|
|
pub(crate) lexical_owner: Option<Id>,
|
|
/// Exact body interior, excluding the opening and closing braces.
|
|
pub(crate) body: Span,
|
|
pub(crate) id_insertion: Option<usize>,
|
|
pub(crate) field_spans: BTreeMap<String, Span>,
|
|
}
|
|
impl Declaration {
|
|
pub(crate) fn is_graph_object(&self) -> bool {
|
|
matches!(self.kind, DeclKind::Element(..) | DeclKind::Relation(..))
|
|
}
|
|
}
|
|
pub(crate) struct SourceHeader {
|
|
pub(crate) project: Project,
|
|
pub(crate) span: Span,
|
|
pub(crate) id_insertion: Option<usize>,
|
|
pub(crate) description: Option<Span>,
|
|
pub(crate) body: Span,
|
|
}
|
|
pub(crate) struct SourceUnit {
|
|
pub(crate) header: Option<SourceHeader>,
|
|
pub(crate) declarations: Vec<Declaration>,
|
|
pub(crate) includes: Vec<(String, Span)>,
|
|
}
|
|
pub(crate) fn parse_unit(
|
|
source: &str,
|
|
entry: bool,
|
|
limits: ParseLimits,
|
|
) -> Result<SourceUnit, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
Parser {
|
|
source,
|
|
tokens,
|
|
cursor: 0,
|
|
value_depth: 0,
|
|
limits,
|
|
}
|
|
.unit(entry, true)
|
|
.map_err(|e| Box::new(diagnostic(source, e)))
|
|
}
|
|
|
|
// Editing positions are derived from the existing, validated parser AST. They
|
|
// do not define another grammar or change the shared V2 migration AST.
|
|
pub(crate) struct EditField {
|
|
pub(crate) span: Span,
|
|
pub(crate) value: EditValue,
|
|
}
|
|
pub(crate) struct EditValue {
|
|
pub(crate) span: Span,
|
|
pub(crate) kind: EditKind,
|
|
}
|
|
pub(crate) enum EditKind {
|
|
Atom,
|
|
Reference(String),
|
|
HistoricalUuid(Id),
|
|
Record(BTreeMap<String, EditField>),
|
|
List(Vec<EditValue>),
|
|
}
|
|
pub(crate) struct EditDeclaration {
|
|
pub(crate) span: Span,
|
|
pub(crate) lexical_owner: Option<Id>,
|
|
pub(crate) body: Span,
|
|
pub(crate) header: BTreeMap<&'static str, Span>,
|
|
pub(crate) fields: EditValue,
|
|
}
|
|
|
|
pub(crate) fn edit_header(source: &str) -> Result<BTreeMap<&'static str, Span>, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
let named = tokens
|
|
.first()
|
|
.is_none_or(|token| token.text(source) != "node");
|
|
let roles: &[&str] = if named {
|
|
&["kind", "alias", "name", "id_keyword", "id"]
|
|
} else {
|
|
&["kind", "alias", "id_keyword", "id"]
|
|
};
|
|
if tokens.len() <= roles.len() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: 0,
|
|
message: "Expected a complete stabilized declaration header.".into(),
|
|
},
|
|
)));
|
|
}
|
|
Ok(roles
|
|
.iter()
|
|
.zip(tokens)
|
|
.map(|(role, token)| (*role, token.span))
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diagnostic>> {
|
|
let unit = parse_unit(source, false, ParseLimits::UNLIMITED)?;
|
|
if unit.declarations.len() != 1 || !unit.includes.is_empty() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: 0,
|
|
message: "Expected one declaration for a source edit.".into(),
|
|
},
|
|
)));
|
|
}
|
|
let mut declarations = edit_positions(source, unit)?;
|
|
Ok(declarations.pop().expect("one declaration").1)
|
|
}
|
|
|
|
/// Editing positions for every occurrence in lexical preorder. This is syntax
|
|
/// analysis only: intermediate command edits may have duplicate identities or
|
|
/// parent fields that disagree with their as-yet unmoved lexical owner. Callers
|
|
/// retain occurrence provenance across such edits, then compile the final text.
|
|
pub(crate) fn edit_declarations(
|
|
source: &str,
|
|
entry: bool,
|
|
limits: ParseLimits,
|
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
|
let unit = parse_unit(source, entry, limits)?;
|
|
edit_positions(source, unit)
|
|
}
|
|
|
|
fn edit_positions(
|
|
source: &str,
|
|
unit: SourceUnit,
|
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
|
let mut stack = Vec::new();
|
|
for (index, token) in tokens.iter().enumerate() {
|
|
match token.text(source) {
|
|
"{" | "[" => stack.push(index),
|
|
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
|
_ => {}
|
|
}
|
|
}
|
|
struct Positions<'a> {
|
|
tokens: &'a [Lexeme],
|
|
ends: Vec<usize>,
|
|
}
|
|
impl Positions<'_> {
|
|
fn token(&self, start: usize) -> usize {
|
|
self.tokens
|
|
.partition_point(|token| token.span.start < start)
|
|
}
|
|
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
|
fields
|
|
.iter()
|
|
.map(|(key, ast)| {
|
|
let value = self.value(ast);
|
|
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
|
let end = self.tokens[self.token(value.span.end)].span.end;
|
|
(
|
|
key.clone(),
|
|
EditField {
|
|
span: Span { start, end },
|
|
value,
|
|
},
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
fn value(&self, ast: &Ast) -> EditValue {
|
|
let index = self.token(ast.offset());
|
|
let span = Span {
|
|
start: ast.offset(),
|
|
end: match ast {
|
|
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
|
_ => self.ends[index],
|
|
},
|
|
};
|
|
let kind = match ast {
|
|
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
|
Ast::Array(items, _) => {
|
|
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
|
}
|
|
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
|
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
|
|
_ => EditKind::Atom,
|
|
};
|
|
EditValue { span, kind }
|
|
}
|
|
}
|
|
let positions = Positions {
|
|
tokens: &tokens,
|
|
ends,
|
|
};
|
|
unit.declarations
|
|
.into_iter()
|
|
.map(|declaration| {
|
|
if declaration.id_insertion.is_some() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: declaration.span.start,
|
|
message: "Stabilize declaration identities before editing source.".into(),
|
|
},
|
|
)));
|
|
}
|
|
let roles: &[&str] = if matches!(declaration.kind, DeclKind::Node) {
|
|
&["kind", "alias", "id_keyword", "id"]
|
|
} else {
|
|
&["kind", "alias", "name", "id_keyword", "id"]
|
|
};
|
|
let start = positions.token(declaration.span.start);
|
|
let header = roles
|
|
.iter()
|
|
.zip(&tokens[start..])
|
|
.map(|(role, token)| (*role, token.span))
|
|
.collect();
|
|
Ok((
|
|
declaration.id,
|
|
EditDeclaration {
|
|
span: declaration.span,
|
|
lexical_owner: declaration.lexical_owner,
|
|
body: declaration.body,
|
|
header,
|
|
fields: EditValue {
|
|
span: Span {
|
|
start: declaration.body.start - 1,
|
|
end: declaration.body.end + 1,
|
|
},
|
|
kind: EditKind::Record(positions.fields(&declaration.fields)),
|
|
},
|
|
},
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
struct Parser<'a> {
|
|
source: &'a str,
|
|
tokens: Vec<Lexeme>,
|
|
cursor: usize,
|
|
value_depth: usize,
|
|
limits: ParseLimits,
|
|
}
|
|
impl Parser<'_> {
|
|
fn current(&self) -> Lexeme {
|
|
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
|
}
|
|
fn text(&self) -> &str {
|
|
self.current().text(self.source)
|
|
}
|
|
fn error(&self, message: impl Into<String>) -> Error {
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: self.current().span.start,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
fn take(&mut self, word: &str) -> Result<(), Error> {
|
|
if self.text() != word {
|
|
return Err(self.error(std::format!("Expected '{word}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(())
|
|
}
|
|
fn quoted(&mut self) -> Result<String, Error> {
|
|
if self.current().class != TokenClass::String {
|
|
return Err(self.error("Expected a double-quoted string."));
|
|
}
|
|
let value = serde_json::from_str(self.text()).unwrap();
|
|
self.cursor += 1;
|
|
Ok(value)
|
|
}
|
|
fn identifier(&mut self) -> Result<String, Error> {
|
|
let value = self.text().to_owned();
|
|
if !crate::valid_alias(&value) {
|
|
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(value)
|
|
}
|
|
fn optional_id(&mut self) -> Result<Id, Error> {
|
|
if self.text() != "id" {
|
|
return Ok(Id::new_v4());
|
|
}
|
|
self.cursor += 1;
|
|
let offset = self.current().span.start;
|
|
let value = self.quoted()?;
|
|
if !has_uuid_header_shape(&value) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
|
|
});
|
|
}
|
|
Id::parse_str(&value).map_err(|_| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Invalid UUID identity.".into(),
|
|
})
|
|
}
|
|
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
|
|
self.take("{")?;
|
|
let mut fields = BTreeMap::new();
|
|
while self.text() != "}" {
|
|
let offset = self.current().span.start;
|
|
let (key, value, _) = self.field()?;
|
|
if fields.insert(key.clone(), value).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: std::format!("Duplicate field '{key}'."),
|
|
});
|
|
}
|
|
}
|
|
self.take("}")?;
|
|
Ok(fields)
|
|
}
|
|
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
|
|
let key = self.text().to_owned();
|
|
if !matches!(
|
|
self.current().class,
|
|
TokenClass::Keyword | TokenClass::Identifier
|
|
) {
|
|
return Err(self.error("Expected a field name or '}'."));
|
|
}
|
|
self.cursor += 1;
|
|
let start = self.current().span.start;
|
|
let value = self.value()?;
|
|
let end = self.tokens[self.cursor - 1].span.end;
|
|
self.take(";")?;
|
|
Ok((key, value, Span { start, end }))
|
|
}
|
|
|
|
fn nested_header_kind(&self) -> Option<DeclKind> {
|
|
let kind = declaration_kind(self.text())?;
|
|
let alias = self.tokens.get(self.cursor + 1)?;
|
|
let following = self.tokens.get(self.cursor + 2)?;
|
|
// A node's ordinary `diagram @view;` field is not a declaration.
|
|
if !crate::valid_alias(alias.text(self.source)) {
|
|
return None;
|
|
}
|
|
if matches!(kind, DeclKind::Node) {
|
|
matches!(following.text(self.source), "id" | "{").then_some(kind)
|
|
} else {
|
|
(following.class == TokenClass::String).then_some(kind)
|
|
}
|
|
}
|
|
fn value(&mut self) -> Result<Ast, Error> {
|
|
if self.value_depth >= 64 {
|
|
return Err(self.error("Typed values may not nest more than 64 levels."));
|
|
}
|
|
self.value_depth += 1;
|
|
let result = self.value_inner();
|
|
self.value_depth -= 1;
|
|
result
|
|
}
|
|
fn value_inner(&mut self) -> Result<Ast, Error> {
|
|
let token = self.current();
|
|
let offset = token.span.start;
|
|
match self.text() {
|
|
"uuid" => {
|
|
self.cursor += 1;
|
|
let value = self.quoted()?;
|
|
let id = Id::parse_str(&value).map_err(|_| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Expected a UUID after uuid.".into(),
|
|
})?;
|
|
Ok(Ast::HistoricalUuid(id, offset))
|
|
}
|
|
"@" => {
|
|
self.cursor += 1;
|
|
Ok(Ast::Reference(self.identifier()?, offset))
|
|
}
|
|
"{" => Ok(Ast::Object(self.fields()?, offset)),
|
|
"[" => {
|
|
self.cursor += 1;
|
|
let mut values = Vec::new();
|
|
if self.text() != "]" {
|
|
loop {
|
|
values.push(self.value()?);
|
|
if self.text() != "," {
|
|
break;
|
|
}
|
|
self.cursor += 1;
|
|
}
|
|
}
|
|
self.take("]")?;
|
|
Ok(Ast::Array(values, offset))
|
|
}
|
|
"none" => {
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(Value::Null, offset))
|
|
}
|
|
"true" | "false" => {
|
|
let value = self.text() == "true";
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(Value::Bool(value), offset))
|
|
}
|
|
_ => match token.class {
|
|
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
|
|
TokenClass::Number => {
|
|
let value = serde_json::from_str(self.text())
|
|
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(value, offset))
|
|
}
|
|
TokenClass::Identifier | TokenClass::Keyword => {
|
|
let value = self.text().to_owned();
|
|
self.cursor += 1;
|
|
Ok(Ast::Word(value, offset))
|
|
}
|
|
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
|
|
},
|
|
}
|
|
}
|
|
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
|
let unit = self.unit(true, false)?;
|
|
let header = unit.header.expect("entry header");
|
|
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
|
|
let mut locations = BTreeMap::from([(header.project.id, header.span.start)]);
|
|
let mut fields = BTreeMap::new();
|
|
for d in &unit.declarations {
|
|
if aliases.insert(d.alias.clone(), d.id).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: d.offset,
|
|
message: format!("Duplicate alias '{}'.", d.alias),
|
|
});
|
|
}
|
|
if locations.insert(d.id, d.offset).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: d.offset,
|
|
message: format!("Duplicate identity '{}'.", d.id),
|
|
});
|
|
}
|
|
fields.extend(
|
|
d.field_spans
|
|
.iter()
|
|
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
|
);
|
|
}
|
|
let decoded =
|
|
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
|
.map_err(|(_, error)| error)?;
|
|
Ok(ParsedDocument {
|
|
project: decoded.project,
|
|
migration: decoded.migration,
|
|
locations,
|
|
fields,
|
|
})
|
|
}
|
|
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
|
let mut header = if entry {
|
|
self.take("arcadia")?;
|
|
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
|
return Err(
|
|
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
|
);
|
|
}
|
|
let schema_version = self.text().parse().expect("checked source version");
|
|
self.cursor += 1;
|
|
self.take(";")?;
|
|
let start = self.current().span.start;
|
|
self.take("project")?;
|
|
let alias = self.identifier()?;
|
|
let name = self.quoted()?;
|
|
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
|
let id = self.optional_id()?;
|
|
let end = self.current().span.start;
|
|
self.take("{")?;
|
|
Some(SourceHeader {
|
|
project: Project {
|
|
schema_version,
|
|
id,
|
|
alias,
|
|
name,
|
|
description: String::new(),
|
|
elements: BTreeMap::new(),
|
|
relations: BTreeMap::new(),
|
|
diagrams: BTreeMap::new(),
|
|
artifact_links: BTreeMap::new(),
|
|
traces: BTreeMap::new(),
|
|
},
|
|
span: Span { start, end },
|
|
id_insertion,
|
|
description: None,
|
|
body: Span {
|
|
start: end + 1,
|
|
end: end + 1,
|
|
},
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let mut declarations: Vec<Declaration> = Vec::new();
|
|
let mut includes = Vec::new();
|
|
let mut graph_count = 0usize;
|
|
// Declarations are a flat preorder with a stack of open body indices.
|
|
// Semantic nesting can be deep without recursing through the Rust stack
|
|
// or conflating declaration depth with the typed-value depth budget.
|
|
let mut open: Vec<usize> = Vec::new();
|
|
loop {
|
|
let offset = self.current().span.start;
|
|
if let Some(&owner) = open.last() {
|
|
if self.text() == "}" {
|
|
declarations[owner].body.end = offset;
|
|
declarations[owner].span.end = self.current().span.end;
|
|
self.cursor += 1;
|
|
open.pop();
|
|
continue;
|
|
}
|
|
if self.text().is_empty() {
|
|
return Err(self.error("Expected a field, element declaration, or '}'."));
|
|
}
|
|
if self.text() == "include" {
|
|
return Err(
|
|
self.error("Includes must occur at document level, outside declarations.")
|
|
);
|
|
}
|
|
if let Some(kind) = self.nested_header_kind() {
|
|
if !matches!(declarations[owner].kind, DeclKind::Element(..))
|
|
|| !matches!(kind, DeclKind::Element(..))
|
|
{
|
|
return Err(self
|
|
.error("Only element declarations may nest inside an element body."));
|
|
}
|
|
} else {
|
|
let (key, value, span) = self.field()?;
|
|
let declaration = &mut declarations[owner];
|
|
if declaration.fields.insert(key.clone(), value).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: format!("Duplicate field '{key}'."),
|
|
});
|
|
}
|
|
declaration.field_spans.insert(key, span);
|
|
continue;
|
|
}
|
|
} else if (entry && self.text() == "}") || (!entry && self.text().is_empty()) {
|
|
break;
|
|
}
|
|
if self.text() == "include" {
|
|
if !includes_allowed {
|
|
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
|
|
}
|
|
self.cursor += 1;
|
|
let path = self.quoted()?;
|
|
self.take(";")?;
|
|
includes.push((
|
|
path,
|
|
Span {
|
|
start: offset,
|
|
end: self.tokens[self.cursor - 1].span.end,
|
|
},
|
|
));
|
|
continue;
|
|
}
|
|
if open.is_empty()
|
|
&& self.text() == "description"
|
|
&& let Some(mut entry_header) = header.take()
|
|
{
|
|
if entry_header.description.is_some() {
|
|
return Err(self.error("Duplicate project description."));
|
|
}
|
|
self.cursor += 1;
|
|
let description = self.quoted()?;
|
|
self.take(";")?;
|
|
entry_header.project.description = description;
|
|
entry_header.description = Some(Span {
|
|
start: offset,
|
|
end: self.tokens[self.cursor - 1].span.end,
|
|
});
|
|
header = Some(entry_header);
|
|
continue;
|
|
}
|
|
let word = self.text().to_owned();
|
|
let kind = declaration_kind(&word)
|
|
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
|
|
graph_count += usize::from(matches!(
|
|
kind,
|
|
DeclKind::Element(..) | DeclKind::Relation(..)
|
|
));
|
|
self.limits
|
|
.check_counts(
|
|
graph_count,
|
|
declarations.len().saturating_add(1 + usize::from(entry)),
|
|
0,
|
|
)
|
|
.map_err(|message| Error {
|
|
code: "model_limit",
|
|
offset,
|
|
message,
|
|
})?;
|
|
self.cursor += 1;
|
|
let alias = self.identifier()?;
|
|
let name = if matches!(kind, DeclKind::Node) {
|
|
String::new()
|
|
} else {
|
|
self.quoted()?
|
|
};
|
|
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
|
let id = self.optional_id()?;
|
|
let body_start = self.current().span.end;
|
|
self.take("{")?;
|
|
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
|
|
declarations.push(Declaration {
|
|
kind,
|
|
alias,
|
|
name,
|
|
id,
|
|
fields: BTreeMap::new(),
|
|
offset,
|
|
span: Span {
|
|
start: offset,
|
|
end: body_start,
|
|
},
|
|
lexical_owner,
|
|
body: Span {
|
|
start: body_start,
|
|
end: body_start,
|
|
},
|
|
id_insertion,
|
|
field_spans: BTreeMap::new(),
|
|
});
|
|
open.push(declarations.len() - 1);
|
|
}
|
|
if entry {
|
|
header.as_mut().expect("entry header").body.end = self.current().span.start;
|
|
self.take("}")?;
|
|
}
|
|
if !self.text().is_empty() {
|
|
return Err(self.error("Unexpected content after the project closing brace."));
|
|
}
|
|
Ok(SourceUnit {
|
|
header,
|
|
declarations,
|
|
includes,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn resolve_declarations(
|
|
mut project: Project,
|
|
declarations: &[Declaration],
|
|
aliases: &BTreeMap<String, Id>,
|
|
limits: ParseLimits,
|
|
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
|
let legacy = project.schema_version == 3;
|
|
let mut legacy_elements = serde_json::Map::new();
|
|
let mut nodes = Vec::new();
|
|
for (index, declaration) in declarations.iter().enumerate() {
|
|
(|| -> Result<(), Error> {
|
|
let mut fields = declaration
|
|
.fields
|
|
.iter()
|
|
.map(|(k, v)| {
|
|
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
|
|
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
|
|
} else { v.resolve(k, aliases)? };
|
|
Ok((k.clone(), value))
|
|
})
|
|
.collect::<Result<Map<_, _>, Error>>()?;
|
|
let d = declaration;
|
|
let conversion_error = |message: String| Error {
|
|
code: "invalid_source",
|
|
offset: d
|
|
.fields
|
|
.iter()
|
|
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
|
|
.map_or(d.offset, |(_, value)| value.offset()),
|
|
message,
|
|
};
|
|
match d.kind {
|
|
DeclKind::Element(kind, layer) => {
|
|
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
|
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
|
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
|
}
|
|
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
|
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
|
}
|
|
let description =
|
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
|
let explicit_parent = d.fields.contains_key("parent");
|
|
let mut parent =
|
|
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
|
if let Some(owner) = d.lexical_owner {
|
|
if explicit_parent && parent != Some(owner) {
|
|
return Err(Error {
|
|
code: "conflicting_parent",
|
|
offset: d.fields["parent"].offset(),
|
|
message: "Explicit parent must equal the containing element; move the declaration to change its lexical parent.".into(),
|
|
});
|
|
}
|
|
parent = Some(owner);
|
|
}
|
|
let mut initial = if kind == Kind::Port {
|
|
if !fields.contains_key("contract") {
|
|
return Err(conversion_error(
|
|
"Missing required field 'contract'.".into(),
|
|
));
|
|
}
|
|
ElementBody::shared_in(
|
|
layer,
|
|
SharedBody::Port {
|
|
contract: PortContract::Legacy,
|
|
legacy: None,
|
|
},
|
|
)
|
|
} else {
|
|
ElementBody::new(kind, layer).unwrap()
|
|
};
|
|
// Schema-2 sources predate explicit scenario viewpoints.
|
|
// Preserve their mixed lifelines instead of reinterpreting
|
|
// old source using a new graphical factory's typed default.
|
|
if !fields.contains_key("viewpoint")
|
|
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
|
|
{
|
|
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
|
}
|
|
let mut body = serde_json::to_value(initial).unwrap();
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Element(kind))
|
|
} else if legacy {
|
|
crate::persistence::v3::legacy_defaults(kind)
|
|
.and_then(|value| value.as_object().cloned())
|
|
.unwrap_or_else(|| payload(&body))
|
|
} else { payload(&body) };
|
|
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
|
replace_payload(&mut body, body_fields);
|
|
if legacy {
|
|
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
|
|
return Ok(());
|
|
}
|
|
let body: ElementBody = serde_json::from_value(body)
|
|
.map_err(|e| conversion_error(e.to_string()))?;
|
|
if body.kind() != kind || body.layer() != layer {
|
|
return Err(conversion_error(
|
|
"Declaration kind disagrees with its typed body.".into(),
|
|
));
|
|
}
|
|
project.elements.insert(
|
|
d.id,
|
|
Element {
|
|
id: d.id,
|
|
alias: d.alias.clone(),
|
|
name: d.name.clone(),
|
|
description,
|
|
parent,
|
|
body,
|
|
},
|
|
);
|
|
}
|
|
DeclKind::Relation(kind) => {
|
|
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
|
|
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
|
|
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
|
}
|
|
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
|
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
|
}
|
|
let description =
|
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
|
let required: &[&str] = if kind == RelationKind::ContextualLink {
|
|
&["owner", "source", "target", "purpose"]
|
|
} else if kind == RelationKind::FunctionalExchange {
|
|
&["source", "target", "source_port", "target_port"]
|
|
} else {
|
|
&["source", "target"]
|
|
};
|
|
for &required in required {
|
|
if !fields.contains_key(required) {
|
|
return Err(conversion_error(std::format!(
|
|
"Missing required field '{required}'."
|
|
)));
|
|
}
|
|
}
|
|
let mut body =
|
|
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
|
.unwrap();
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Relation(kind))
|
|
} else {
|
|
payload(&body)
|
|
};
|
|
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
|
replace_payload(&mut body, merged);
|
|
let body = serde_json::from_value(body)
|
|
.map_err(|e| conversion_error(e.to_string()))?;
|
|
project.relations.insert(
|
|
d.id,
|
|
Relation {
|
|
id: d.id,
|
|
alias: d.alias.clone(),
|
|
name: d.name.clone(),
|
|
description,
|
|
body,
|
|
},
|
|
);
|
|
}
|
|
DeclKind::Diagram => {
|
|
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
|
if project.schema_version < 6 && fields.contains_key("view_options") {
|
|
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
|
}
|
|
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
|
|
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
|
}
|
|
let defaults =
|
|
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Diagram)
|
|
} else { defaults.as_object().unwrap().clone() };
|
|
let mut record = merge_fields(defaults, fields)
|
|
.map_err(&conversion_error)?;
|
|
header(&mut record, d, true);
|
|
record.insert("nodes".into(), serde_json::json!({}));
|
|
let diagram: Diagram = serde_json::from_value(Value::Object(record))
|
|
.map_err(|e| conversion_error(e.to_string()))?;
|
|
project.diagrams.insert(d.id, diagram);
|
|
}
|
|
DeclKind::Node => {
|
|
let diagram =
|
|
take_required_id(&mut fields, "diagram").map_err(&conversion_error)?;
|
|
if !fields.contains_key("element") {
|
|
return Err(conversion_error("Missing required field 'element'.".into()));
|
|
}
|
|
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Node)
|
|
} else { defaults.as_object().unwrap().clone() };
|
|
let mut record = merge_fields(defaults, fields)
|
|
.map_err(&conversion_error)?;
|
|
header(&mut record, d, false);
|
|
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
|
.map_err(|e| conversion_error(e.to_string()))?;
|
|
nodes.push((diagram, node, d.offset, index));
|
|
}
|
|
DeclKind::Artifact => {
|
|
if !fields.contains_key("owner") {
|
|
return Err(conversion_error("Missing required field 'owner'.".into()));
|
|
}
|
|
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Artifact)
|
|
} else { defaults.as_object().unwrap().clone() };
|
|
let mut record = merge_fields(defaults, fields)
|
|
.map_err(&conversion_error)?;
|
|
header(&mut record, d, true);
|
|
let artifact = serde_json::from_value(Value::Object(record))
|
|
.map_err(|e| conversion_error(e.to_string()))?;
|
|
project.artifact_links.insert(d.id, artifact);
|
|
}
|
|
DeclKind::Trace => {
|
|
for required in ["oa", "sa"] {
|
|
if !fields.contains_key(required) {
|
|
return Err(conversion_error(std::format!(
|
|
"Missing required field '{required}'."
|
|
)));
|
|
}
|
|
}
|
|
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
|
let defaults = if project.schema_version >= 7 {
|
|
source_defaults::parser_fields(DefaultContext::Trace)
|
|
} else { defaults.as_object().unwrap().clone() };
|
|
let mut record = merge_fields(defaults, fields)
|
|
.map_err(&conversion_error)?;
|
|
header(&mut record, d, true);
|
|
let trace = if project.schema_version < 5 {
|
|
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
|
|
Trace::from(old)
|
|
} else {
|
|
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
|
|
};
|
|
project.traces.insert(d.id, trace);
|
|
}
|
|
}
|
|
Ok(())
|
|
})().map_err(|error| (index, error))?;
|
|
}
|
|
for (diagram, node, offset, index) in nodes {
|
|
let target = project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.ok_or_else(|| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Node 'diagram' must reference a diagram declaration.".into(),
|
|
})
|
|
.map_err(|error| (index, error))?;
|
|
target.nodes.insert(node.id, node);
|
|
}
|
|
if legacy {
|
|
let mut value = serde_json::to_value(&project).expect("project serialization");
|
|
value["elements"] = Value::Object(legacy_elements);
|
|
let migrated = serde_json::from_value(value)
|
|
.map_err(crate::persistence::FileError::Json)
|
|
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
|
.map_err(|error| {
|
|
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
|
issues.first()
|
|
} else {
|
|
None
|
|
};
|
|
let index = issue
|
|
.and_then(|issue| issue.element)
|
|
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
|
.unwrap_or(usize::MAX);
|
|
let offset = declarations.get(index).map_or(0, |d| {
|
|
issue
|
|
.and_then(|issue| issue.field.as_ref())
|
|
.and_then(|field| d.field_spans.get(field))
|
|
.map_or(d.offset, |span| span.start)
|
|
});
|
|
(
|
|
index,
|
|
Error {
|
|
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
|
"model_limit"
|
|
} else {
|
|
"migration_error"
|
|
},
|
|
offset,
|
|
message: error.to_string(),
|
|
},
|
|
)
|
|
})?;
|
|
for (index, d) in declarations.iter().enumerate() {
|
|
if let DeclKind::Element(kind, layer) = d.kind {
|
|
let body = &migrated.project.elements[&d.id].body;
|
|
if body.kind() != kind || body.layer() != layer {
|
|
return Err((
|
|
index,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: d.offset,
|
|
message: "Declaration kind disagrees with its typed body.".into(),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
return Ok(migrated);
|
|
}
|
|
if matches!(project.schema_version, 4..=6) {
|
|
return (if project.schema_version == 4 {
|
|
crate::persistence::v4::finish(project, limits)
|
|
} else if project.schema_version == 5 {
|
|
crate::persistence::migrate_v5(project, limits)
|
|
} else {
|
|
crate::persistence::migrate_v6(project, limits)
|
|
})
|
|
.map_err(|error| {
|
|
(
|
|
usize::MAX,
|
|
Error {
|
|
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
|
"model_limit"
|
|
} else {
|
|
"migration_error"
|
|
},
|
|
offset: 0,
|
|
message: error.to_string(),
|
|
},
|
|
)
|
|
});
|
|
}
|
|
limits.check_model(&project).map_err(|message| {
|
|
(
|
|
usize::MAX,
|
|
Error {
|
|
code: "model_limit",
|
|
offset: 0,
|
|
message,
|
|
},
|
|
)
|
|
})?;
|
|
Ok(crate::persistence::DecodeResult {
|
|
project,
|
|
migration: None,
|
|
})
|
|
}
|
|
|
|
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
|
fields.insert("id".into(), Value::String(d.id.to_string()));
|
|
fields.insert("alias".into(), Value::String(d.alias.clone()));
|
|
if named {
|
|
fields.insert("name".into(), Value::String(d.name.clone()));
|
|
}
|
|
}
|
|
fn merge_fields(
|
|
mut defaults: Map<String, Value>,
|
|
fields: Map<String, Value>,
|
|
) -> Result<Map<String, Value>, String> {
|
|
for (key, value) in fields {
|
|
if !defaults.contains_key(&key) {
|
|
return Err(std::format!("Unknown field '{key}' for this declaration."));
|
|
}
|
|
defaults.insert(key, value);
|
|
}
|
|
Ok(defaults)
|
|
}
|
|
fn take_string(
|
|
fields: &mut Map<String, Value>,
|
|
key: &str,
|
|
default: &str,
|
|
) -> Result<String, String> {
|
|
match fields.remove(key) {
|
|
None => Ok(default.into()),
|
|
Some(Value::String(value)) => Ok(value),
|
|
_ => Err(std::format!("Field '{key}' requires a string.")),
|
|
}
|
|
}
|
|
fn take_optional_id(fields: &mut Map<String, Value>, key: &str) -> Result<Option<Id>, String> {
|
|
match fields.remove(key) {
|
|
None | Some(Value::Null) => Ok(None),
|
|
Some(Value::String(value)) => Id::parse_str(&value)
|
|
.map(Some)
|
|
.map_err(|_| std::format!("Field '{key}' requires an @alias reference.")),
|
|
_ => Err(std::format!(
|
|
"Field '{key}' requires an @alias reference or none."
|
|
)),
|
|
}
|
|
}
|
|
fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, String> {
|
|
take_optional_id(fields, key)?
|
|
.ok_or_else(|| std::format!("Missing required @alias field '{key}'."))
|
|
}
|
|
|
|
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
|
/// spans remain available even when a later declaration contains errors.
|
|
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
|
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
|
pub fn analyze(source: &str) -> Analysis {
|
|
let tokens = tokenize(source);
|
|
let (declarations, _) =
|
|
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
|
Analysis {
|
|
tokens,
|
|
diagnostics: lint(source),
|
|
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
|
}
|
|
}
|
|
|
|
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
|
/// Source bytes are admitted before token allocation; token/declaration limits
|
|
/// are checked before lexing the next token or materializing the next record.
|
|
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
|
if source.len() > limits.max_source_bytes {
|
|
return Err(OutlineLimit {
|
|
resource: OutlineResource::SourceBytes,
|
|
limit: limits.max_source_bytes,
|
|
observed_at_least: source.len(),
|
|
offset: 0,
|
|
});
|
|
}
|
|
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
|
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
|
let (declarations, has_unmatched_braces) =
|
|
declaration_outline(source, &tokens, limits.max_declarations)?;
|
|
Ok(Outline {
|
|
source,
|
|
tokens,
|
|
declarations,
|
|
has_lexical_errors,
|
|
has_unmatched_braces,
|
|
})
|
|
}
|
|
|
|
fn declaration_outline(
|
|
source: &str,
|
|
tokens: &[TokenSpan],
|
|
max_declarations: usize,
|
|
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
|
let significant = tokens
|
|
.iter()
|
|
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
|
.collect::<Vec<_>>();
|
|
// Recover matched body bounds once. Scanning each aggregate's descendants
|
|
// again would make tolerant navigation quadratic on deeply nested drafts.
|
|
let mut body_ends = vec![None; significant.len()];
|
|
let mut bodies = Vec::new();
|
|
let mut unmatched_close = false;
|
|
for (index, token) in significant.iter().enumerate() {
|
|
match &source[token.span.start..token.span.end] {
|
|
"{" => bodies.push(index),
|
|
"}" => {
|
|
if let Some(open) = bodies.pop() {
|
|
body_ends[open] = Some(token.span.end);
|
|
} else {
|
|
unmatched_close = true;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
|
let mut declarations = Vec::new();
|
|
for (i, t) in significant.iter().enumerate() {
|
|
let word = &source[t.span.start..t.span.end];
|
|
if declaration_kind(word).is_none() && word != "project" {
|
|
continue;
|
|
}
|
|
let Some(alias_token) = significant.get(i + 1) else {
|
|
continue;
|
|
};
|
|
let alias = &source[alias_token.span.start..alias_token.span.end];
|
|
if !crate::valid_alias(alias) {
|
|
continue;
|
|
}
|
|
let mut j = i + 2;
|
|
let named = word != "node";
|
|
if named {
|
|
if significant
|
|
.get(j)
|
|
.is_none_or(|t| t.class != TokenClass::String)
|
|
{
|
|
continue;
|
|
}
|
|
j += 1;
|
|
}
|
|
if declarations.len() == max_declarations {
|
|
return Err(OutlineLimit {
|
|
resource: OutlineResource::Declarations,
|
|
limit: max_declarations,
|
|
observed_at_least: declarations.len().saturating_add(1),
|
|
offset: t.span.start,
|
|
});
|
|
}
|
|
let mut id = None;
|
|
let mut id_literal_span = None;
|
|
if significant
|
|
.get(j)
|
|
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
|
{
|
|
if let Some(value) = significant.get(j + 1)
|
|
&& value.class == TokenClass::String
|
|
{
|
|
id_literal_span = Some(value.span);
|
|
let text =
|
|
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
|
.unwrap();
|
|
if has_uuid_header_shape(&text) {
|
|
id = Id::parse_str(&text).ok();
|
|
}
|
|
}
|
|
j += 2;
|
|
}
|
|
// A recognized header ID at EOF must remain inside the whole span.
|
|
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
|
let header_end = significant
|
|
.get(j.saturating_sub(1))
|
|
.or_else(|| significant.last())
|
|
.map_or(alias_token.span.end, |token| token.span.end);
|
|
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
|
if significant
|
|
.get(j)
|
|
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
|
{
|
|
end = body_ends[j].unwrap_or(source.len());
|
|
}
|
|
declarations.push(OutlineDeclaration {
|
|
declaration: DeclarationSpan {
|
|
alias: alias.into(),
|
|
id,
|
|
kind: word.into(),
|
|
span: Span {
|
|
start: t.span.start,
|
|
end,
|
|
},
|
|
},
|
|
kind_span: t.span,
|
|
alias_span: alias_token.span,
|
|
id_literal_span,
|
|
});
|
|
}
|
|
Ok((declarations, has_unmatched_braces))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "language_outline_tests.rs"]
|
|
mod outline_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "language_nesting_tests.rs"]
|
|
mod nesting_tests;
|
|
|
|
pub(crate) mod legacy {
|
|
//! Arcadia text: a readable, lossless representation of a valid OA project.
|
|
//!
|
|
//! Parsing creates a candidate only. Callers adopt it through `Session` so that
|
|
//! graphical and textual edits share validation, history, and saved-state rules.
|
|
use crate::persistence::v1::{
|
|
Element, Id, Kind, Position, Project, Relation, RelationKind, validation,
|
|
};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fmt;
|
|
|
|
/// A source error. Positions are one-based and columns count Unicode scalars.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Diagnostic {
|
|
pub code: &'static str,
|
|
pub line: usize,
|
|
pub column: usize,
|
|
pub message: String,
|
|
}
|
|
|
|
impl fmt::Display for Diagnostic {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}:{}: {}", self.line, self.column, self.message)
|
|
}
|
|
}
|
|
|
|
/// Parse an entire document, resolve forward references, and validate its model.
|
|
/// Missing `id` clauses generate fresh identities; canonical output includes them.
|
|
/// Incomplete models with warnings are accepted. Syntax and structural errors are
|
|
/// rejected, without returning a partially usable model.
|
|
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
|
parse_limited(source, super::ParseLimits::UNLIMITED)
|
|
}
|
|
|
|
pub(super) fn parse_limited(
|
|
source: &str,
|
|
limits: super::ParseLimits,
|
|
) -> Result<Project, Vec<Diagnostic>> {
|
|
let tokens = lex(source).map_err(|error| vec![diagnostic(source, error)])?;
|
|
Parser {
|
|
tokens,
|
|
cursor: 0,
|
|
limits,
|
|
}
|
|
.document()
|
|
.map_err(|error| vec![diagnostic(source, error)])
|
|
}
|
|
|
|
/// Produce deterministic canonical text for a structurally valid project.
|
|
/// All persistent model values and identities are represented. Local aliases are
|
|
/// derived from display names and are not persistent model identity. Formatting
|
|
/// discards source comments and whitespace. Invalid projects must be validated
|
|
/// before formatting; only valid projects have the round-trip guarantee.
|
|
pub fn format(project: &Project) -> String {
|
|
let mut used = BTreeSet::new();
|
|
let mut aliases = BTreeMap::new();
|
|
for element in project.elements.values() {
|
|
aliases.insert(
|
|
element.id,
|
|
alias(&element.name, kind_word(element.kind), &mut used),
|
|
);
|
|
}
|
|
for relation in project.relations.values() {
|
|
aliases.insert(
|
|
relation.id,
|
|
alias(&relation.name, relation_word(relation.kind), &mut used),
|
|
);
|
|
}
|
|
let reference = |id: Id| {
|
|
aliases
|
|
.get(&id)
|
|
.cloned()
|
|
.unwrap_or_else(|| std::format!("missing_{}", id.simple()))
|
|
};
|
|
let mut text = std::format!(
|
|
"arcadia {};\nproject {} id \"{}\" {{\n",
|
|
project.schema_version,
|
|
quote(&project.name),
|
|
project.id,
|
|
);
|
|
for element in project.elements.values() {
|
|
text.push_str(&std::format!(
|
|
" {} {} {} id \"{}\" {{\n description {};\n position ({}, {});\n",
|
|
kind_word(element.kind),
|
|
reference(element.id),
|
|
quote(&element.name),
|
|
element.id,
|
|
quote(&element.description),
|
|
element.position.x,
|
|
element.position.y,
|
|
));
|
|
if element.kind == Kind::Process {
|
|
text.push_str(&std::format!(
|
|
" steps [{}];\n",
|
|
element
|
|
.steps
|
|
.iter()
|
|
.map(|id| reference(*id))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
}
|
|
text.push_str(" }\n\n");
|
|
}
|
|
for relation in project.relations.values() {
|
|
text.push_str(&std::format!(
|
|
" {} {} {} id \"{}\" from {} to {};\n",
|
|
relation_word(relation.kind),
|
|
reference(relation.id),
|
|
quote(&relation.name),
|
|
relation.id,
|
|
reference(relation.source),
|
|
reference(relation.target),
|
|
));
|
|
}
|
|
text.push_str("}\n");
|
|
text
|
|
}
|
|
|
|
fn quote(value: &str) -> String {
|
|
serde_json::to_string(value).expect("serializing a Rust string cannot fail")
|
|
}
|
|
|
|
fn alias(name: &str, fallback: &str, used: &mut BTreeSet<String>) -> String {
|
|
let mut stem = String::new();
|
|
for ch in name.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
stem.push(ch.to_ascii_lowercase());
|
|
} else if !stem.is_empty() && !stem.ends_with('_') {
|
|
stem.push('_');
|
|
}
|
|
}
|
|
let mut stem = stem.trim_end_matches('_').to_owned();
|
|
if stem.is_empty() {
|
|
stem = std::format!("{fallback}_item");
|
|
} else if stem.as_bytes()[0].is_ascii_digit() || reserved(&stem) {
|
|
stem = std::format!("{fallback}_{stem}");
|
|
}
|
|
let mut candidate = stem.clone();
|
|
let mut suffix = 2;
|
|
while !used.insert(candidate.clone()) {
|
|
candidate = std::format!("{stem}_{suffix}");
|
|
suffix += 1;
|
|
}
|
|
candidate
|
|
}
|
|
|
|
fn kind_word(kind: Kind) -> &'static str {
|
|
match kind {
|
|
Kind::Entity => "entity",
|
|
Kind::Actor => "actor",
|
|
Kind::Activity => "activity",
|
|
Kind::Capability => "capability",
|
|
Kind::Process => "process",
|
|
}
|
|
}
|
|
|
|
fn relation_word(kind: RelationKind) -> &'static str {
|
|
match kind {
|
|
RelationKind::Allocation => "allocation",
|
|
RelationKind::Interaction => "interaction",
|
|
RelationKind::Contribution => "contribution",
|
|
RelationKind::Realization => "realization",
|
|
}
|
|
}
|
|
|
|
fn reserved(word: &str) -> bool {
|
|
matches!(
|
|
word,
|
|
"arcadia"
|
|
| "project"
|
|
| "id"
|
|
| "entity"
|
|
| "actor"
|
|
| "activity"
|
|
| "capability"
|
|
| "process"
|
|
| "allocation"
|
|
| "interaction"
|
|
| "contribution"
|
|
| "realization"
|
|
| "description"
|
|
| "position"
|
|
| "steps"
|
|
| "from"
|
|
| "to"
|
|
)
|
|
}
|
|
|
|
struct Error {
|
|
code: &'static str,
|
|
offset: usize,
|
|
message: String,
|
|
}
|
|
|
|
fn diagnostic(source: &str, error: Error) -> Diagnostic {
|
|
let prefix = &source[..error.offset];
|
|
Diagnostic {
|
|
code: error.code,
|
|
line: prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
|
column: prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1,
|
|
message: error.message,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum TokenKind {
|
|
Word(String),
|
|
Quoted(String),
|
|
Number(String),
|
|
Symbol(char),
|
|
End,
|
|
}
|
|
|
|
struct Token {
|
|
kind: TokenKind,
|
|
offset: usize,
|
|
}
|
|
|
|
fn lex(source: &str) -> Result<Vec<Token>, Error> {
|
|
let mut result = Vec::new();
|
|
let mut cursor = 0;
|
|
while cursor < source.len() {
|
|
let start = cursor;
|
|
let ch = source[cursor..].chars().next().unwrap();
|
|
if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
if source[cursor..].starts_with("//") {
|
|
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
|
|
continue;
|
|
}
|
|
let kind = if ch == '"' {
|
|
cursor += 1;
|
|
let mut escaped = false;
|
|
let mut closed = false;
|
|
for next in source[cursor..].chars() {
|
|
cursor += next.len_utf8();
|
|
if escaped {
|
|
escaped = false;
|
|
} else if next == '\\' {
|
|
escaped = true;
|
|
} else if next == '"' {
|
|
closed = true;
|
|
break;
|
|
}
|
|
}
|
|
if !closed {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: start,
|
|
message: "Unterminated quoted string.".into(),
|
|
});
|
|
}
|
|
TokenKind::Quoted(serde_json::from_str(&source[start..cursor]).map_err(
|
|
|error| Error {
|
|
code: "invalid_source",
|
|
offset: start,
|
|
message: std::format!("Invalid quoted string: {error}"),
|
|
},
|
|
)?)
|
|
} else if ch.is_ascii_alphabetic() || ch == '_' {
|
|
cursor += 1;
|
|
while source
|
|
.as_bytes()
|
|
.get(cursor)
|
|
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
|
|
{
|
|
cursor += 1;
|
|
}
|
|
TokenKind::Word(source[start..cursor].into())
|
|
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
|
|
cursor += 1;
|
|
while source.as_bytes().get(cursor).is_some_and(|next| {
|
|
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
|
|
}) {
|
|
cursor += 1;
|
|
}
|
|
TokenKind::Number(source[start..cursor].into())
|
|
} else if "{}();,[]".contains(ch) {
|
|
cursor += 1;
|
|
TokenKind::Symbol(ch)
|
|
} else {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: start,
|
|
message: std::format!("Unexpected character {ch:?}."),
|
|
});
|
|
};
|
|
result.push(Token {
|
|
kind,
|
|
offset: start,
|
|
});
|
|
}
|
|
result.push(Token {
|
|
kind: TokenKind::End,
|
|
offset: source.len(),
|
|
});
|
|
Ok(result)
|
|
}
|
|
|
|
struct Reference {
|
|
alias: String,
|
|
offset: usize,
|
|
}
|
|
|
|
enum Declaration {
|
|
Element(Element, Vec<Reference>),
|
|
Relation(Id, RelationKind, String, Reference, Reference),
|
|
}
|
|
|
|
struct Parser {
|
|
tokens: Vec<Token>,
|
|
cursor: usize,
|
|
limits: super::ParseLimits,
|
|
}
|
|
|
|
impl Parser {
|
|
fn current(&self) -> &Token {
|
|
&self.tokens[self.cursor]
|
|
}
|
|
|
|
fn error(&self, message: impl Into<String>) -> Error {
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: self.current().offset,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn word_is(&self, word: &str) -> bool {
|
|
matches!(&self.current().kind, TokenKind::Word(actual) if actual == word)
|
|
}
|
|
|
|
fn symbol_is(&self, symbol: char) -> bool {
|
|
matches!(self.current().kind, TokenKind::Symbol(actual) if actual == symbol)
|
|
}
|
|
|
|
fn word(&mut self, word: &str) -> Result<(), Error> {
|
|
if !self.word_is(word) {
|
|
return Err(self.error(std::format!("Expected '{word}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(())
|
|
}
|
|
|
|
fn symbol(&mut self, symbol: char) -> Result<(), Error> {
|
|
if !self.symbol_is(symbol) {
|
|
return Err(self.error(std::format!("Expected '{symbol}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(())
|
|
}
|
|
|
|
fn quoted(&mut self) -> Result<String, Error> {
|
|
let TokenKind::Quoted(value) = &self.current().kind else {
|
|
return Err(self.error("Expected a double-quoted string."));
|
|
};
|
|
let value = value.clone();
|
|
self.cursor += 1;
|
|
Ok(value)
|
|
}
|
|
|
|
fn reference(&mut self) -> Result<Reference, Error> {
|
|
let TokenKind::Word(alias) = &self.current().kind else {
|
|
return Err(self.error("Expected an alias (letters, digits, or underscores)."));
|
|
};
|
|
if reserved(alias) {
|
|
return Err(
|
|
self.error(std::format!("'{alias}' is reserved; choose another alias."))
|
|
);
|
|
}
|
|
let reference = Reference {
|
|
alias: alias.clone(),
|
|
offset: self.current().offset,
|
|
};
|
|
self.cursor += 1;
|
|
Ok(reference)
|
|
}
|
|
|
|
fn optional_id(&mut self) -> Result<Id, Error> {
|
|
if !self.word_is("id") {
|
|
return Ok(Id::new_v4());
|
|
}
|
|
self.cursor += 1;
|
|
let offset = self.current().offset;
|
|
let value = self.quoted()?;
|
|
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
|
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
|
if !super::has_uuid_header_shape(&value) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Expected a UUID string in 8-4-4-4-12 hexadecimal form.".into(),
|
|
});
|
|
}
|
|
Id::parse_str(&value).map_err(|_| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Invalid UUID: use 8-4-4-4-12 hexadecimal form.".into(),
|
|
})
|
|
}
|
|
|
|
fn number(&mut self) -> Result<f32, Error> {
|
|
let TokenKind::Number(value) = &self.current().kind else {
|
|
return Err(self.error("Expected a finite decimal coordinate."));
|
|
};
|
|
// JSON establishes an exact lexical grammar; Rust's f32 parser retains
|
|
// negative zero and supplies the actual model's IEEE-754 rounding.
|
|
if serde_json::from_str::<serde_json::Number>(value).is_err() {
|
|
return Err(self.error("Invalid number: use JSON decimal or exponent notation."));
|
|
}
|
|
let parsed = value
|
|
.parse::<f32>()
|
|
.ok()
|
|
.filter(|number| number.is_finite())
|
|
.ok_or_else(|| self.error("Coordinate must be a finite 32-bit number."))?;
|
|
self.cursor += 1;
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn document(&mut self) -> Result<Project, Error> {
|
|
self.word("arcadia")?;
|
|
let version_offset = self.current().offset;
|
|
let TokenKind::Number(version) = &self.current().kind else {
|
|
return Err(self.error("Expected the language version number 1."));
|
|
};
|
|
let schema_version = version
|
|
.parse::<u32>()
|
|
.map_err(|_| self.error("Expected an unsigned integer language version."))?;
|
|
if version != &schema_version.to_string() {
|
|
return Err(self.error("Use an unsigned decimal version without leading zeros."));
|
|
}
|
|
self.cursor += 1;
|
|
self.symbol(';')?;
|
|
self.word("project")?;
|
|
let project_offset = self.current().offset;
|
|
let name = self.quoted()?;
|
|
let id = self.optional_id()?;
|
|
let mut project = Project {
|
|
schema_version,
|
|
id,
|
|
name,
|
|
elements: BTreeMap::new(),
|
|
relations: BTreeMap::new(),
|
|
aliases: BTreeMap::new(),
|
|
};
|
|
self.symbol('{')?;
|
|
let mut aliases = BTreeMap::new();
|
|
let mut locations = BTreeMap::from([(id, project_offset)]);
|
|
let mut declarations = Vec::new();
|
|
while !self.symbol_is('}') {
|
|
self.limits
|
|
.check_counts(
|
|
declarations.len().saturating_add(1),
|
|
declarations.len().saturating_add(2),
|
|
0,
|
|
)
|
|
.map_err(|message| Error {
|
|
code: "model_limit",
|
|
offset: self.current().offset,
|
|
message,
|
|
})?;
|
|
let offset = self.current().offset;
|
|
let (kind, relation_kind) = match &self.current().kind {
|
|
TokenKind::Word(word) => match word.as_str() {
|
|
"entity" => (Some(Kind::Entity), None),
|
|
"actor" => (Some(Kind::Actor), None),
|
|
"activity" => (Some(Kind::Activity), None),
|
|
"capability" => (Some(Kind::Capability), None),
|
|
"process" => (Some(Kind::Process), None),
|
|
"allocation" => (None, Some(RelationKind::Allocation)),
|
|
"interaction" => (None, Some(RelationKind::Interaction)),
|
|
"contribution" => (None, Some(RelationKind::Contribution)),
|
|
"realization" => (None, Some(RelationKind::Realization)),
|
|
_ => return Err(self.error(std::format!("Unknown declaration '{word}'."))),
|
|
},
|
|
_ => return Err(self.error("Expected an element, relationship, or '}'.")),
|
|
};
|
|
self.cursor += 1;
|
|
let alias = self.reference()?;
|
|
let name = self.quoted()?;
|
|
let id = self.optional_id()?;
|
|
if aliases.insert(alias.alias.clone(), id).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: alias.offset,
|
|
message: std::format!("Duplicate alias '{}'.", alias.alias),
|
|
});
|
|
}
|
|
if locations.insert(id, offset).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: alias.offset,
|
|
message: std::format!(
|
|
"Duplicate identity '{id}'; all IDs must be unique, including the project ID."
|
|
),
|
|
});
|
|
}
|
|
if let Some(kind) = kind {
|
|
let (element, steps) = self.element(id, kind, name)?;
|
|
declarations.push(Declaration::Element(element, steps));
|
|
} else if let Some(kind) = relation_kind {
|
|
self.word("from")?;
|
|
let source = self.reference()?;
|
|
self.word("to")?;
|
|
let target = self.reference()?;
|
|
self.symbol(';')?;
|
|
declarations.push(Declaration::Relation(id, kind, name, source, target));
|
|
}
|
|
}
|
|
self.symbol('}')?;
|
|
if !matches!(self.current().kind, TokenKind::End) {
|
|
return Err(self.error("Unexpected content after the project closing brace."));
|
|
}
|
|
let resolve = |reference: &Reference| {
|
|
aliases.get(&reference.alias).copied().ok_or_else(|| Error {
|
|
code: "invalid_source",
|
|
offset: reference.offset,
|
|
message: std::format!("Unknown reference '{}'.", reference.alias),
|
|
})
|
|
};
|
|
project.aliases = aliases
|
|
.iter()
|
|
.map(|(alias, id)| (*id, alias.clone()))
|
|
.collect();
|
|
for declaration in declarations {
|
|
match declaration {
|
|
Declaration::Element(mut element, steps) => {
|
|
element.steps = steps.iter().map(resolve).collect::<Result<_, _>>()?;
|
|
project.elements.insert(element.id, element);
|
|
}
|
|
Declaration::Relation(id, kind, name, source, target) => {
|
|
project.relations.insert(
|
|
id,
|
|
Relation {
|
|
id,
|
|
kind,
|
|
name,
|
|
source: resolve(&source)?,
|
|
target: resolve(&target)?,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
crate::persistence::check_v1_limits(&project, self.limits).map_err(|message| {
|
|
Error {
|
|
code: "model_limit",
|
|
offset: self.current().offset,
|
|
message,
|
|
}
|
|
})?;
|
|
if let Some(issue) = validation::validate(&project)
|
|
.into_iter()
|
|
.find(|issue| issue.severity == validation::Severity::Error)
|
|
{
|
|
let offset = if schema_version != 1 {
|
|
version_offset
|
|
} else {
|
|
issue
|
|
.element
|
|
.and_then(|id| locations.get(&id).copied())
|
|
.unwrap_or(project_offset)
|
|
};
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: issue.message,
|
|
});
|
|
}
|
|
Ok(project)
|
|
}
|
|
|
|
fn element(
|
|
&mut self,
|
|
id: Id,
|
|
kind: Kind,
|
|
name: String,
|
|
) -> Result<(Element, Vec<Reference>), Error> {
|
|
self.symbol('{')?;
|
|
let mut element = Element {
|
|
id,
|
|
kind,
|
|
name,
|
|
description: String::new(),
|
|
position: Position { x: 0., y: 0. },
|
|
steps: Vec::new(),
|
|
};
|
|
let mut fields = BTreeSet::new();
|
|
let mut steps = Vec::new();
|
|
while !self.symbol_is('}') {
|
|
let TokenKind::Word(field) = self.current().kind.clone() else {
|
|
return Err(self.error("Expected 'description', 'position', 'steps', or '}'."));
|
|
};
|
|
if !fields.insert(field.clone()) {
|
|
return Err(self.error(std::format!("Duplicate field '{field}'.")));
|
|
}
|
|
match field.as_str() {
|
|
"description" => {
|
|
self.cursor += 1;
|
|
element.description = self.quoted()?;
|
|
}
|
|
"position" => {
|
|
self.cursor += 1;
|
|
self.symbol('(')?;
|
|
element.position.x = self.number()?;
|
|
self.symbol(',')?;
|
|
element.position.y = self.number()?;
|
|
self.symbol(')')?;
|
|
}
|
|
"steps" if kind == Kind::Process => {
|
|
self.cursor += 1;
|
|
self.symbol('[')?;
|
|
if !self.symbol_is(']') {
|
|
loop {
|
|
steps.push(self.reference()?);
|
|
if !self.symbol_is(',') {
|
|
break;
|
|
}
|
|
self.cursor += 1;
|
|
}
|
|
}
|
|
self.symbol(']')?;
|
|
}
|
|
"steps" => return Err(self.error("Only processes can contain steps.")),
|
|
_ => return Err(self.error(std::format!("Unknown field '{field}'."))),
|
|
}
|
|
self.symbol(';')?;
|
|
}
|
|
self.symbol('}')?;
|
|
Ok((element, steps))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Closed historical facet identity for the targeted source writer.
|
|
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
|
semantic_source::row_key(source, origins)
|
|
}
|