Preserve loaded draft bytes and model history, reject stale workspace and input events, and keep captured Escape under modal priority. Verified 181 focused Windows tests and strict desktop Clippy on exact inputs4; earlier full737 scope and component/native preparation evidence are archived separately. Native navigation acceptance remains pending.
2833 lines
103 KiB
Rust
2833 lines
103 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;
|
|
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. 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<T: serde::Serialize>(value: &T) -> Map<String, Value> {
|
|
let mut fields = serde_json::to_value(value)
|
|
.unwrap()
|
|
.as_object()
|
|
.unwrap()
|
|
.clone();
|
|
for field in ["id", "alias", "name"] {
|
|
fields.remove(field);
|
|
}
|
|
fields
|
|
}
|
|
fn payload(value: &Value) -> Map<String, Value> {
|
|
match value {
|
|
Value::Object(m)
|
|
if m.len() == 1
|
|
&& m.keys()
|
|
.next()
|
|
.unwrap()
|
|
.chars()
|
|
.next()
|
|
.unwrap()
|
|
.is_ascii_uppercase() =>
|
|
{
|
|
payload(m.values().next().unwrap())
|
|
}
|
|
Value::Object(m) => m.clone(),
|
|
Value::String(_) => Map::new(),
|
|
_ => unreachable!("typed enum body"),
|
|
}
|
|
}
|
|
fn replace_payload(value: &mut Value, fields: Map<String, Value>) {
|
|
match value {
|
|
Value::Object(m)
|
|
if m.len() == 1
|
|
&& m.keys()
|
|
.next()
|
|
.unwrap()
|
|
.chars()
|
|
.next()
|
|
.unwrap()
|
|
.is_ascii_uppercase() =>
|
|
{
|
|
replace_payload(m.values_mut().next().unwrap(), fields)
|
|
}
|
|
Value::Object(m) => *m = fields,
|
|
Value::String(_) => debug_assert!(fields.is_empty()),
|
|
_ => unreachable!("typed enum body"),
|
|
}
|
|
}
|
|
fn write_declaration(
|
|
out: &mut String,
|
|
kind: &str,
|
|
alias: &str,
|
|
name: Option<&str>,
|
|
id: Id,
|
|
fields: &Map<String, Value>,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
out.push_str(&std::format!(" {kind} {alias}"));
|
|
if let Some(name) = name {
|
|
out.push(' ');
|
|
out.push_str("e(name));
|
|
}
|
|
out.push_str(&std::format!(" id \"{id}\" {{\n"));
|
|
write_fields(out, fields, 2, aliases);
|
|
out.push_str(" }\n\n");
|
|
}
|
|
fn write_fields(
|
|
out: &mut String,
|
|
fields: &Map<String, Value>,
|
|
indent: usize,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
for (key, value) in fields {
|
|
out.push_str(&" ".repeat(indent));
|
|
out.push_str(key);
|
|
out.push(' ');
|
|
write_value(out, value, key, indent, aliases);
|
|
out.push_str(";\n");
|
|
}
|
|
}
|
|
fn write_value(
|
|
out: &mut String,
|
|
value: &Value,
|
|
key: &str,
|
|
indent: usize,
|
|
aliases: &BTreeMap<Id, String>,
|
|
) {
|
|
if key == "semantic" {
|
|
semantic_source::write(out, value, indent);
|
|
return;
|
|
}
|
|
match value {
|
|
Value::Null => out.push_str("none"),
|
|
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
|
Value::Number(v) => out.push_str(&v.to_string()),
|
|
Value::String(v) => {
|
|
if reference_field(key)
|
|
&& let Ok(id) = Id::parse_str(v)
|
|
&& let Some(alias) = aliases.get(&id)
|
|
{
|
|
out.push('@');
|
|
out.push_str(alias);
|
|
return;
|
|
}
|
|
if enum_field(key) && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
out.push_str(v)
|
|
} else {
|
|
out.push_str("e(v))
|
|
}
|
|
}
|
|
Value::Array(values) => {
|
|
out.push('[');
|
|
for (i, v) in values.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push_str(", ");
|
|
}
|
|
write_value(out, v, key, indent, aliases);
|
|
}
|
|
out.push(']');
|
|
}
|
|
Value::Object(fields) => {
|
|
out.push_str("{\n");
|
|
write_fields(out, fields, indent + 1, aliases);
|
|
out.push_str(&" ".repeat(indent));
|
|
out.push('}');
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Ast {
|
|
Literal(Value, usize),
|
|
Word(String, usize),
|
|
Reference(String, usize),
|
|
HistoricalUuid(Id, usize),
|
|
Array(Vec<Ast>, usize),
|
|
Object(BTreeMap<String, Ast>, usize),
|
|
}
|
|
impl Ast {
|
|
fn offset(&self) -> usize {
|
|
match self {
|
|
Self::Literal(_, p)
|
|
| Self::Word(_, p)
|
|
| Self::Reference(_, p)
|
|
| Self::HistoricalUuid(_, p)
|
|
| Self::Array(_, p)
|
|
| Self::Object(_, p) => *p,
|
|
}
|
|
}
|
|
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
|
|
match self {
|
|
Self::HistoricalUuid(_, _) => Err(Error {
|
|
code: "invalid_source",
|
|
offset: self.offset(),
|
|
message:
|
|
"A uuid literal is allowed only in a typed historical semantic reference slot."
|
|
.into(),
|
|
}),
|
|
Self::Word(word, offset) => {
|
|
if !enum_field(key) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires a quoted string or a typed value; '{word}' is not an enum field."
|
|
),
|
|
});
|
|
}
|
|
Ok(Value::String(word.clone()))
|
|
}
|
|
Self::Reference(alias, offset) => {
|
|
if !reference_field(key) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!("Field '{key}' does not accept an @alias reference."),
|
|
});
|
|
}
|
|
aliases
|
|
.get(alias)
|
|
.map(|id| Value::String(id.to_string()))
|
|
.ok_or_else(|| Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!("Unknown reference '@{alias}'."),
|
|
})
|
|
}
|
|
Self::Literal(value, offset) => {
|
|
if reference_field(key) && value.is_string() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires an @alias reference, not a quoted UUID or enum."
|
|
),
|
|
});
|
|
}
|
|
if enum_field(key) && value.is_string() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: *offset,
|
|
message: std::format!(
|
|
"Field '{key}' requires an unquoted enum word or typed constructor."
|
|
),
|
|
});
|
|
}
|
|
Ok(value.clone())
|
|
}
|
|
Self::Array(items, _) => Ok(Value::Array(
|
|
items
|
|
.iter()
|
|
.map(|v| v.resolve(key, aliases))
|
|
.collect::<Result<_, _>>()?,
|
|
)),
|
|
Self::Object(fields, _) => Ok(Value::Object(
|
|
fields
|
|
.iter()
|
|
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
|
|
.collect::<Result<_, Error>>()?,
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
struct Lexeme {
|
|
class: TokenClass,
|
|
span: Span,
|
|
}
|
|
impl Lexeme {
|
|
fn text<'a>(&self, source: &'a str) -> &'a str {
|
|
&source[self.span.start..self.span.end]
|
|
}
|
|
}
|
|
fn strict_tokens(source: &str) -> Result<Vec<Lexeme>, Error> {
|
|
let mut out = Vec::new();
|
|
for t in tokenize(source) {
|
|
if t.class == TokenClass::Error {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: t.span.start,
|
|
message: if source[t.span.start..].starts_with('"') {
|
|
"Invalid or unterminated quoted string.".into()
|
|
} else {
|
|
std::format!("Invalid token '{}'.", &source[t.span.start..t.span.end])
|
|
},
|
|
});
|
|
}
|
|
if !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment) {
|
|
out.push(Lexeme {
|
|
class: t.class,
|
|
span: t.span,
|
|
});
|
|
}
|
|
}
|
|
out.push(Lexeme {
|
|
class: TokenClass::Punctuation,
|
|
span: Span {
|
|
start: source.len(),
|
|
end: source.len(),
|
|
},
|
|
});
|
|
Ok(out)
|
|
}
|
|
pub(crate) struct Declaration {
|
|
kind: DeclKind,
|
|
pub(crate) alias: String,
|
|
name: String,
|
|
pub(crate) id: Id,
|
|
fields: BTreeMap<String, Ast>,
|
|
offset: usize,
|
|
pub(crate) span: Span,
|
|
/// The containing element's identity, independent of an authored parent field.
|
|
pub(crate) lexical_owner: Option<Id>,
|
|
/// Exact body interior, excluding the opening and closing braces.
|
|
pub(crate) body: Span,
|
|
pub(crate) id_insertion: Option<usize>,
|
|
pub(crate) field_spans: BTreeMap<String, Span>,
|
|
}
|
|
impl Declaration {
|
|
pub(crate) fn is_graph_object(&self) -> bool {
|
|
matches!(self.kind, DeclKind::Element(..) | DeclKind::Relation(..))
|
|
}
|
|
}
|
|
pub(crate) struct SourceHeader {
|
|
pub(crate) project: Project,
|
|
pub(crate) span: Span,
|
|
pub(crate) id_insertion: Option<usize>,
|
|
pub(crate) description: Option<Span>,
|
|
pub(crate) body: Span,
|
|
}
|
|
pub(crate) struct SourceUnit {
|
|
pub(crate) header: Option<SourceHeader>,
|
|
pub(crate) declarations: Vec<Declaration>,
|
|
pub(crate) includes: Vec<(String, Span)>,
|
|
}
|
|
pub(crate) fn parse_unit(
|
|
source: &str,
|
|
entry: bool,
|
|
limits: ParseLimits,
|
|
) -> Result<SourceUnit, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
Parser {
|
|
source,
|
|
tokens,
|
|
cursor: 0,
|
|
value_depth: 0,
|
|
limits,
|
|
}
|
|
.unit(entry, true)
|
|
.map_err(|e| Box::new(diagnostic(source, e)))
|
|
}
|
|
|
|
// Editing positions are derived from the existing, validated parser AST. They
|
|
// do not define another grammar or change the shared V2 migration AST.
|
|
pub(crate) struct EditField {
|
|
pub(crate) span: Span,
|
|
pub(crate) value: EditValue,
|
|
}
|
|
pub(crate) struct EditValue {
|
|
pub(crate) span: Span,
|
|
pub(crate) kind: EditKind,
|
|
}
|
|
pub(crate) enum EditKind {
|
|
Atom,
|
|
Reference(String),
|
|
HistoricalUuid(Id),
|
|
Record(BTreeMap<String, EditField>),
|
|
List(Vec<EditValue>),
|
|
}
|
|
pub(crate) struct EditDeclaration {
|
|
pub(crate) span: Span,
|
|
pub(crate) lexical_owner: Option<Id>,
|
|
pub(crate) body: Span,
|
|
pub(crate) header: BTreeMap<&'static str, Span>,
|
|
pub(crate) fields: EditValue,
|
|
}
|
|
|
|
pub(crate) fn edit_header(source: &str) -> Result<BTreeMap<&'static str, Span>, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
let named = tokens
|
|
.first()
|
|
.is_none_or(|token| token.text(source) != "node");
|
|
let roles: &[&str] = if named {
|
|
&["kind", "alias", "name", "id_keyword", "id"]
|
|
} else {
|
|
&["kind", "alias", "id_keyword", "id"]
|
|
};
|
|
if tokens.len() <= roles.len() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: 0,
|
|
message: "Expected a complete stabilized declaration header.".into(),
|
|
},
|
|
)));
|
|
}
|
|
Ok(roles
|
|
.iter()
|
|
.zip(tokens)
|
|
.map(|(role, token)| (*role, token.span))
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diagnostic>> {
|
|
let unit = parse_unit(source, false, ParseLimits::UNLIMITED)?;
|
|
if unit.declarations.len() != 1 || !unit.includes.is_empty() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: 0,
|
|
message: "Expected one declaration for a source edit.".into(),
|
|
},
|
|
)));
|
|
}
|
|
let mut declarations = edit_positions(source, unit)?;
|
|
Ok(declarations.pop().expect("one declaration").1)
|
|
}
|
|
|
|
/// Editing positions for every occurrence in lexical preorder. This is syntax
|
|
/// analysis only: intermediate command edits may have duplicate identities or
|
|
/// parent fields that disagree with their as-yet unmoved lexical owner. Callers
|
|
/// retain occurrence provenance across such edits, then compile the final text.
|
|
pub(crate) fn edit_declarations(
|
|
source: &str,
|
|
entry: bool,
|
|
limits: ParseLimits,
|
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
|
let unit = parse_unit(source, entry, limits)?;
|
|
edit_positions(source, unit)
|
|
}
|
|
|
|
fn edit_positions(
|
|
source: &str,
|
|
unit: SourceUnit,
|
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
|
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
|
let mut stack = Vec::new();
|
|
for (index, token) in tokens.iter().enumerate() {
|
|
match token.text(source) {
|
|
"{" | "[" => stack.push(index),
|
|
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
|
_ => {}
|
|
}
|
|
}
|
|
struct Positions<'a> {
|
|
tokens: &'a [Lexeme],
|
|
ends: Vec<usize>,
|
|
}
|
|
impl Positions<'_> {
|
|
fn token(&self, start: usize) -> usize {
|
|
self.tokens
|
|
.partition_point(|token| token.span.start < start)
|
|
}
|
|
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
|
fields
|
|
.iter()
|
|
.map(|(key, ast)| {
|
|
let value = self.value(ast);
|
|
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
|
let end = self.tokens[self.token(value.span.end)].span.end;
|
|
(
|
|
key.clone(),
|
|
EditField {
|
|
span: Span { start, end },
|
|
value,
|
|
},
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
fn value(&self, ast: &Ast) -> EditValue {
|
|
let index = self.token(ast.offset());
|
|
let span = Span {
|
|
start: ast.offset(),
|
|
end: match ast {
|
|
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
|
_ => self.ends[index],
|
|
},
|
|
};
|
|
let kind = match ast {
|
|
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
|
Ast::Array(items, _) => {
|
|
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
|
}
|
|
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
|
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
|
|
_ => EditKind::Atom,
|
|
};
|
|
EditValue { span, kind }
|
|
}
|
|
}
|
|
let positions = Positions {
|
|
tokens: &tokens,
|
|
ends,
|
|
};
|
|
unit.declarations
|
|
.into_iter()
|
|
.map(|declaration| {
|
|
if declaration.id_insertion.is_some() {
|
|
return Err(Box::new(diagnostic(
|
|
source,
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: declaration.span.start,
|
|
message: "Stabilize declaration identities before editing source.".into(),
|
|
},
|
|
)));
|
|
}
|
|
let roles: &[&str] = if matches!(declaration.kind, DeclKind::Node) {
|
|
&["kind", "alias", "id_keyword", "id"]
|
|
} else {
|
|
&["kind", "alias", "name", "id_keyword", "id"]
|
|
};
|
|
let start = positions.token(declaration.span.start);
|
|
let header = roles
|
|
.iter()
|
|
.zip(&tokens[start..])
|
|
.map(|(role, token)| (*role, token.span))
|
|
.collect();
|
|
Ok((
|
|
declaration.id,
|
|
EditDeclaration {
|
|
span: declaration.span,
|
|
lexical_owner: declaration.lexical_owner,
|
|
body: declaration.body,
|
|
header,
|
|
fields: EditValue {
|
|
span: Span {
|
|
start: declaration.body.start - 1,
|
|
end: declaration.body.end + 1,
|
|
},
|
|
kind: EditKind::Record(positions.fields(&declaration.fields)),
|
|
},
|
|
},
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
struct Parser<'a> {
|
|
source: &'a str,
|
|
tokens: Vec<Lexeme>,
|
|
cursor: usize,
|
|
value_depth: usize,
|
|
limits: ParseLimits,
|
|
}
|
|
impl Parser<'_> {
|
|
fn current(&self) -> Lexeme {
|
|
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
|
}
|
|
fn text(&self) -> &str {
|
|
self.current().text(self.source)
|
|
}
|
|
fn error(&self, message: impl Into<String>) -> Error {
|
|
Error {
|
|
code: "invalid_source",
|
|
offset: self.current().span.start,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
fn take(&mut self, word: &str) -> Result<(), Error> {
|
|
if self.text() != word {
|
|
return Err(self.error(std::format!("Expected '{word}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(())
|
|
}
|
|
fn quoted(&mut self) -> Result<String, Error> {
|
|
if self.current().class != TokenClass::String {
|
|
return Err(self.error("Expected a double-quoted string."));
|
|
}
|
|
let value = serde_json::from_str(self.text()).unwrap();
|
|
self.cursor += 1;
|
|
Ok(value)
|
|
}
|
|
fn identifier(&mut self) -> Result<String, Error> {
|
|
let value = self.text().to_owned();
|
|
if !crate::valid_alias(&value) {
|
|
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
|
|
}
|
|
self.cursor += 1;
|
|
Ok(value)
|
|
}
|
|
fn optional_id(&mut self) -> Result<Id, Error> {
|
|
if self.text() != "id" {
|
|
return Ok(Id::new_v4());
|
|
}
|
|
self.cursor += 1;
|
|
let offset = self.current().span.start;
|
|
let value = self.quoted()?;
|
|
if !has_uuid_header_shape(&value) {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
|
|
});
|
|
}
|
|
Id::parse_str(&value).map_err(|_| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Invalid UUID identity.".into(),
|
|
})
|
|
}
|
|
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
|
|
self.take("{")?;
|
|
let mut fields = BTreeMap::new();
|
|
while self.text() != "}" {
|
|
let offset = self.current().span.start;
|
|
let (key, value, _) = self.field()?;
|
|
if fields.insert(key.clone(), value).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: std::format!("Duplicate field '{key}'."),
|
|
});
|
|
}
|
|
}
|
|
self.take("}")?;
|
|
Ok(fields)
|
|
}
|
|
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
|
|
let key = self.text().to_owned();
|
|
if !matches!(
|
|
self.current().class,
|
|
TokenClass::Keyword | TokenClass::Identifier
|
|
) {
|
|
return Err(self.error("Expected a field name or '}'."));
|
|
}
|
|
self.cursor += 1;
|
|
let start = self.current().span.start;
|
|
let value = self.value()?;
|
|
let end = self.tokens[self.cursor - 1].span.end;
|
|
self.take(";")?;
|
|
Ok((key, value, Span { start, end }))
|
|
}
|
|
|
|
fn nested_header_kind(&self) -> Option<DeclKind> {
|
|
let kind = declaration_kind(self.text())?;
|
|
let alias = self.tokens.get(self.cursor + 1)?;
|
|
let following = self.tokens.get(self.cursor + 2)?;
|
|
// A node's ordinary `diagram @view;` field is not a declaration.
|
|
if !crate::valid_alias(alias.text(self.source)) {
|
|
return None;
|
|
}
|
|
if matches!(kind, DeclKind::Node) {
|
|
matches!(following.text(self.source), "id" | "{").then_some(kind)
|
|
} else {
|
|
(following.class == TokenClass::String).then_some(kind)
|
|
}
|
|
}
|
|
fn value(&mut self) -> Result<Ast, Error> {
|
|
if self.value_depth >= 64 {
|
|
return Err(self.error("Typed values may not nest more than 64 levels."));
|
|
}
|
|
self.value_depth += 1;
|
|
let result = self.value_inner();
|
|
self.value_depth -= 1;
|
|
result
|
|
}
|
|
fn value_inner(&mut self) -> Result<Ast, Error> {
|
|
let token = self.current();
|
|
let offset = token.span.start;
|
|
match self.text() {
|
|
"uuid" => {
|
|
self.cursor += 1;
|
|
let value = self.quoted()?;
|
|
let id = Id::parse_str(&value).map_err(|_| Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: "Expected a UUID after uuid.".into(),
|
|
})?;
|
|
Ok(Ast::HistoricalUuid(id, offset))
|
|
}
|
|
"@" => {
|
|
self.cursor += 1;
|
|
Ok(Ast::Reference(self.identifier()?, offset))
|
|
}
|
|
"{" => Ok(Ast::Object(self.fields()?, offset)),
|
|
"[" => {
|
|
self.cursor += 1;
|
|
let mut values = Vec::new();
|
|
if self.text() != "]" {
|
|
loop {
|
|
values.push(self.value()?);
|
|
if self.text() != "," {
|
|
break;
|
|
}
|
|
self.cursor += 1;
|
|
}
|
|
}
|
|
self.take("]")?;
|
|
Ok(Ast::Array(values, offset))
|
|
}
|
|
"none" => {
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(Value::Null, offset))
|
|
}
|
|
"true" | "false" => {
|
|
let value = self.text() == "true";
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(Value::Bool(value), offset))
|
|
}
|
|
_ => match token.class {
|
|
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
|
|
TokenClass::Number => {
|
|
let value = serde_json::from_str(self.text())
|
|
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
|
|
self.cursor += 1;
|
|
Ok(Ast::Literal(value, offset))
|
|
}
|
|
TokenClass::Identifier | TokenClass::Keyword => {
|
|
let value = self.text().to_owned();
|
|
self.cursor += 1;
|
|
Ok(Ast::Word(value, offset))
|
|
}
|
|
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
|
|
},
|
|
}
|
|
}
|
|
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
|
let unit = self.unit(true, false)?;
|
|
let header = unit.header.expect("entry header");
|
|
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
|
|
let mut locations = BTreeMap::from([(header.project.id, header.span.start)]);
|
|
let mut fields = BTreeMap::new();
|
|
for d in &unit.declarations {
|
|
if aliases.insert(d.alias.clone(), d.id).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: d.offset,
|
|
message: format!("Duplicate alias '{}'.", d.alias),
|
|
});
|
|
}
|
|
if locations.insert(d.id, d.offset).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset: d.offset,
|
|
message: format!("Duplicate identity '{}'.", d.id),
|
|
});
|
|
}
|
|
fields.extend(
|
|
d.field_spans
|
|
.iter()
|
|
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
|
);
|
|
}
|
|
let decoded =
|
|
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
|
.map_err(|(_, error)| error)?;
|
|
Ok(ParsedDocument {
|
|
project: decoded.project,
|
|
migration: decoded.migration,
|
|
locations,
|
|
fields,
|
|
})
|
|
}
|
|
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
|
let mut header = if entry {
|
|
self.take("arcadia")?;
|
|
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
|
return Err(
|
|
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
|
);
|
|
}
|
|
let schema_version = self.text().parse().expect("checked source version");
|
|
self.cursor += 1;
|
|
self.take(";")?;
|
|
let start = self.current().span.start;
|
|
self.take("project")?;
|
|
let alias = self.identifier()?;
|
|
let name = self.quoted()?;
|
|
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
|
let id = self.optional_id()?;
|
|
let end = self.current().span.start;
|
|
self.take("{")?;
|
|
Some(SourceHeader {
|
|
project: Project {
|
|
schema_version,
|
|
id,
|
|
alias,
|
|
name,
|
|
description: String::new(),
|
|
elements: BTreeMap::new(),
|
|
relations: BTreeMap::new(),
|
|
diagrams: BTreeMap::new(),
|
|
artifact_links: BTreeMap::new(),
|
|
traces: BTreeMap::new(),
|
|
},
|
|
span: Span { start, end },
|
|
id_insertion,
|
|
description: None,
|
|
body: Span {
|
|
start: end + 1,
|
|
end: end + 1,
|
|
},
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let mut declarations: Vec<Declaration> = Vec::new();
|
|
let mut includes = Vec::new();
|
|
let mut graph_count = 0usize;
|
|
// Declarations are a flat preorder with a stack of open body indices.
|
|
// Semantic nesting can be deep without recursing through the Rust stack
|
|
// or conflating declaration depth with the typed-value depth budget.
|
|
let mut open: Vec<usize> = Vec::new();
|
|
loop {
|
|
let offset = self.current().span.start;
|
|
if let Some(&owner) = open.last() {
|
|
if self.text() == "}" {
|
|
declarations[owner].body.end = offset;
|
|
declarations[owner].span.end = self.current().span.end;
|
|
self.cursor += 1;
|
|
open.pop();
|
|
continue;
|
|
}
|
|
if self.text().is_empty() {
|
|
return Err(self.error("Expected a field, element declaration, or '}'."));
|
|
}
|
|
if self.text() == "include" {
|
|
return Err(
|
|
self.error("Includes must occur at document level, outside declarations.")
|
|
);
|
|
}
|
|
if let Some(kind) = self.nested_header_kind() {
|
|
if !matches!(declarations[owner].kind, DeclKind::Element(..))
|
|
|| !matches!(kind, DeclKind::Element(..))
|
|
{
|
|
return Err(self
|
|
.error("Only element declarations may nest inside an element body."));
|
|
}
|
|
} else {
|
|
let (key, value, span) = self.field()?;
|
|
let declaration = &mut declarations[owner];
|
|
if declaration.fields.insert(key.clone(), value).is_some() {
|
|
return Err(Error {
|
|
code: "invalid_source",
|
|
offset,
|
|
message: format!("Duplicate field '{key}'."),
|
|
});
|
|
}
|
|
declaration.field_spans.insert(key, span);
|
|
continue;
|
|
}
|
|
} else if (entry && self.text() == "}") || (!entry && self.text().is_empty()) {
|
|
break;
|
|
}
|
|
if self.text() == "include" {
|
|
if !includes_allowed {
|
|
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
|
|
}
|
|
self.cursor += 1;
|
|
let path = self.quoted()?;
|
|
self.take(";")?;
|
|
includes.push((
|
|
path,
|
|
Span {
|
|
start: offset,
|
|
end: self.tokens[self.cursor - 1].span.end,
|
|
},
|
|
));
|
|
continue;
|
|
}
|
|
if open.is_empty()
|
|
&& self.text() == "description"
|
|
&& let Some(mut entry_header) = header.take()
|
|
{
|
|
if entry_header.description.is_some() {
|
|
return Err(self.error("Duplicate project description."));
|
|
}
|
|
self.cursor += 1;
|
|
let description = self.quoted()?;
|
|
self.take(";")?;
|
|
entry_header.project.description = description;
|
|
entry_header.description = Some(Span {
|
|
start: offset,
|
|
end: self.tokens[self.cursor - 1].span.end,
|
|
});
|
|
header = Some(entry_header);
|
|
continue;
|
|
}
|
|
let word = self.text().to_owned();
|
|
let kind = declaration_kind(&word)
|
|
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
|
|
graph_count += usize::from(matches!(
|
|
kind,
|
|
DeclKind::Element(..) | DeclKind::Relation(..)
|
|
));
|
|
self.limits
|
|
.check_counts(
|
|
graph_count,
|
|
declarations.len().saturating_add(1 + usize::from(entry)),
|
|
0,
|
|
)
|
|
.map_err(|message| Error {
|
|
code: "model_limit",
|
|
offset,
|
|
message,
|
|
})?;
|
|
self.cursor += 1;
|
|
let alias = self.identifier()?;
|
|
let name = if matches!(kind, DeclKind::Node) {
|
|
String::new()
|
|
} else {
|
|
self.quoted()?
|
|
};
|
|
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
|
let id = self.optional_id()?;
|
|
let body_start = self.current().span.end;
|
|
self.take("{")?;
|
|
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
|
|
declarations.push(Declaration {
|
|
kind,
|
|
alias,
|
|
name,
|
|
id,
|
|
fields: BTreeMap::new(),
|
|
offset,
|
|
span: Span {
|
|
start: offset,
|
|
end: body_start,
|
|
},
|
|
lexical_owner,
|
|
body: Span {
|
|
start: body_start,
|
|
end: body_start,
|
|
},
|
|
id_insertion,
|
|
field_spans: BTreeMap::new(),
|
|
});
|
|
open.push(declarations.len() - 1);
|
|
}
|
|
if entry {
|
|
header.as_mut().expect("entry header").body.end = self.current().span.start;
|
|
self.take("}")?;
|
|
}
|
|
if !self.text().is_empty() {
|
|
return Err(self.error("Unexpected content after the project closing brace."));
|
|
}
|
|
Ok(SourceUnit {
|
|
header,
|
|
declarations,
|
|
includes,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn resolve_declarations(
|
|
mut project: Project,
|
|
declarations: &[Declaration],
|
|
aliases: &BTreeMap<String, Id>,
|
|
limits: ParseLimits,
|
|
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
|
let legacy = project.schema_version == 3;
|
|
let mut legacy_elements = serde_json::Map::new();
|
|
let mut nodes = Vec::new();
|
|
for (index, declaration) in declarations.iter().enumerate() {
|
|
(|| -> Result<(), Error> {
|
|
let mut fields = declaration
|
|
.fields
|
|
.iter()
|
|
.map(|(k, v)| {
|
|
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
|
|
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
|
|
} else { v.resolve(k, aliases)? };
|
|
Ok((k.clone(), value))
|
|
})
|
|
.collect::<Result<Map<_, _>, Error>>()?;
|
|
let d = declaration;
|
|
let conversion_error = |message: String| Error {
|
|
code: "invalid_source",
|
|
offset: d
|
|
.fields
|
|
.iter()
|
|
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
|
|
.map_or(d.offset, |(_, value)| value.offset()),
|
|
message,
|
|
};
|
|
match d.kind {
|
|
DeclKind::Element(kind, layer) => {
|
|
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
|
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
|
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
|
}
|
|
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
|
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
|
}
|
|
let description =
|
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
|
let explicit_parent = d.fields.contains_key("parent");
|
|
let mut parent =
|
|
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
|
if let Some(owner) = d.lexical_owner {
|
|
if explicit_parent && parent != Some(owner) {
|
|
return Err(Error {
|
|
code: "conflicting_parent",
|
|
offset: d.fields["parent"].offset(),
|
|
message: "Explicit parent must equal the containing element; move the declaration to change its lexical parent.".into(),
|
|
});
|
|
}
|
|
parent = Some(owner);
|
|
}
|
|
let mut initial = if kind == Kind::Port {
|
|
if !fields.contains_key("contract") {
|
|
return Err(conversion_error(
|
|
"Missing required field 'contract'.".into(),
|
|
));
|
|
}
|
|
ElementBody::shared_in(
|
|
layer,
|
|
SharedBody::Port {
|
|
contract: PortContract::Legacy,
|
|
legacy: None,
|
|
},
|
|
)
|
|
} else {
|
|
ElementBody::new(kind, layer).unwrap()
|
|
};
|
|
// Schema-2 sources predate explicit scenario viewpoints.
|
|
// Preserve their mixed lifelines instead of reinterpreting
|
|
// old source using a new graphical factory's typed default.
|
|
if !fields.contains_key("viewpoint")
|
|
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
|
|
{
|
|
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
|
}
|
|
let mut body = serde_json::to_value(initial).unwrap();
|
|
let defaults = if 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()});
|
|
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<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)
|
|
}
|