//! 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; 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, pub field: Option, 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::(), ) }) .sum::(), ) .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::() .saturating_add( project .relations .values() .map(|r| match &r.body { RelationBody::ContextualLink { source, target, .. } => source .references .len() .saturating_add(target.references.len()), _ => 0, }) .sum::(), ); 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, pub kind: String, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Analysis { pub tokens: Vec, pub diagnostics: Vec, pub declarations: Vec, } /// 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, } /// 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, pub declarations: Vec, 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 { tokenize_with_limit(source, usize::MAX).expect("unlimited token scan") } fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result, 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::(&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::(&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 { 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 { static KINDS: std::sync::OnceLock> = 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> { parse_limited(source, ParseLimits::UNLIMITED) } pub fn parse_limited(source: &str, limits: ParseLimits) -> Result> { parse_with_report_limited(source, limits).map(|decoded| decoded.project) } pub fn parse_with_report( source: &str, ) -> Result> { parse_with_report_limited(source, ParseLimits::UNLIMITED) } pub fn parse_with_report_limited( source: &str, limits: ParseLimits, ) -> Result> { 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, locations: BTreeMap, fields: BTreeMap<(Id, String), usize>, } impl ParsedDocument { fn diagnostics(&self, source: &str) -> Vec { // 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> { 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::>() })?; 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. 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 description {};\n\n", project.alias, quote(&project.name), project.id, quote(&project.description) ); for (_, declaration) in format_declarations(project) { out.push_str(&declaration); } out.push_str("}\n"); out } /// The same typed declaration writer is used by flat and modular source output. pub(crate) fn format_declarations(project: &Project) -> 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()); 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())); 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"); 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())); 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, &record_fields(a), &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, &record_fields(t), &aliases, ); declarations.push((t.id, out)); } declarations } fn record_fields(value: &T) -> Map { 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 { 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) { 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, aliases: &BTreeMap, ) { 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, indent: usize, aliases: &BTreeMap, ) { 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, ) { 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, usize), Object(BTreeMap, 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) -> Result { 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::>()?, )), Self::Object(fields, _) => Ok(Value::Object( fields .iter() .map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?))) .collect::>()?, )), } } } #[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, 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, offset: usize, pub(crate) span: Span, /// The containing element's identity, independent of an authored parent field. pub(crate) lexical_owner: Option, /// Exact body interior, excluding the opening and closing braces. pub(crate) body: Span, pub(crate) id_insertion: Option, pub(crate) field_spans: BTreeMap, } 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, pub(crate) description: Option, pub(crate) body: Span, } pub(crate) struct SourceUnit { pub(crate) header: Option, pub(crate) declarations: Vec, pub(crate) includes: Vec<(String, Span)>, } pub(crate) fn parse_unit( source: &str, entry: bool, limits: ParseLimits, ) -> Result> { 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), List(Vec), } pub(crate) struct EditDeclaration { pub(crate) span: Span, pub(crate) lexical_owner: Option, pub(crate) body: Span, pub(crate) header: BTreeMap<&'static str, Span>, pub(crate) fields: EditValue, } pub(crate) fn edit_header(source: &str) -> Result, Box> { 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> { 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, Box> { let unit = parse_unit(source, entry, limits)?; edit_positions(source, unit) } fn edit_positions( source: &str, unit: SourceUnit, ) -> Result, Box> { 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, } impl Positions<'_> { fn token(&self, start: usize) -> usize { self.tokens .partition_point(|token| token.span.start < start) } fn fields(&self, fields: &BTreeMap) -> BTreeMap { 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, 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) -> 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 { 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 { 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 { 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, 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 { 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 { 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 { 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 { 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 { 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 = 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 = 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, limits: ParseLimits, ) -> Result { 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::, 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 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 merged = merge_fields(payload(&body), 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(),"layout":null}); let mut record = merge_fields(defaults.as_object().unwrap().clone(), 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 mut record = merge_fields(defaults.as_object().unwrap().clone(), 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 mut record = merge_fields(defaults.as_object().unwrap().clone(), 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 mut record = merge_fields(defaults.as_object().unwrap().clone(), 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, 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, fields: Map, ) -> Result, 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, key: &str, default: &str, ) -> Result { 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, key: &str) -> Result, 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, key: &str) -> Result { 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, 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, bool), OutlineLimit> { let significant = tokens .iter() .filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment)) .collect::>(); // 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::(&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> { parse_limited(source, super::ParseLimits::UNLIMITED) } pub(super) fn parse_limited( source: &str, limits: super::ParseLimits, ) -> Result> { 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::>() .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 { 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, 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), Relation(Id, RelationKind, String, Reference, Reference), } struct Parser { tokens: Vec, cursor: usize, limits: super::ParseLimits, } impl Parser { fn current(&self) -> &Token { &self.tokens[self.cursor] } fn error(&self, message: impl Into) -> 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 { 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 { 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 { 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 { 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::(value).is_err() { return Err(self.error("Invalid number: use JSON decimal or exponent notation.")); } let parsed = value .parse::() .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 { 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::() .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::>()?; 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), 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) -> Option { semantic_source::row_key(source, origins) }