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

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(&quote(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(&quote(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)
}