From f1d79df4979121335ed7b4ce4da84b2fca8a9ef4 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 13 Sep 2026 00:34:59 -0700 Subject: [PATCH] Stage bounded typed source8 canonical and subtree writers --- .../oa-core/src/language/analysis_source.rs | 4 + .../language/analysis_source/lower/tests.rs | 3 +- .../{lower/oracle.rs => model_oracle.rs} | 0 .../src/language/analysis_source/write.rs | 664 +++++++++++++ .../analysis_source/write/expression.rs | 284 ++++++ .../language/analysis_source/write/tests.rs | 910 ++++++++++++++++++ 6 files changed, 1863 insertions(+), 2 deletions(-) rename crates/oa-core/src/language/analysis_source/{lower/oracle.rs => model_oracle.rs} (100%) create mode 100644 crates/oa-core/src/language/analysis_source/write.rs create mode 100644 crates/oa-core/src/language/analysis_source/write/expression.rs create mode 100644 crates/oa-core/src/language/analysis_source/write/tests.rs diff --git a/crates/oa-core/src/language/analysis_source.rs b/crates/oa-core/src/language/analysis_source.rs index 5de053bd..6db10341 100644 --- a/crates/oa-core/src/language/analysis_source.rs +++ b/crates/oa-core/src/language/analysis_source.rs @@ -6,6 +6,10 @@ mod declarations; mod lex; mod lower; mod syntax; +mod write; + +#[cfg(test)] +mod model_oracle; use super::Span; use crate::analysis::{BinaryOp, UnaryOp}; diff --git a/crates/oa-core/src/language/analysis_source/lower/tests.rs b/crates/oa-core/src/language/analysis_source/lower/tests.rs index 89ef442c..812fd585 100644 --- a/crates/oa-core/src/language/analysis_source/lower/tests.rs +++ b/crates/oa-core/src/language/analysis_source/lower/tests.rs @@ -2,8 +2,7 @@ use super::*; use crate::language::ParseLimits; use crate::language::analysis_source::{budget::Limits, cursor::Cursor}; -#[path = "oracle.rs"] -mod oracle; +use super::super::model_oracle as oracle; const OA: &str = include_str!("fixtures/typed-oa.arcadia"); const SA: &str = include_str!("fixtures/typed-sa.arcadia"); diff --git a/crates/oa-core/src/language/analysis_source/lower/oracle.rs b/crates/oa-core/src/language/analysis_source/model_oracle.rs similarity index 100% rename from crates/oa-core/src/language/analysis_source/lower/oracle.rs rename to crates/oa-core/src/language/analysis_source/model_oracle.rs diff --git a/crates/oa-core/src/language/analysis_source/write.rs b/crates/oa-core/src/language/analysis_source/write.rs new file mode 100644 index 00000000..3c3a8ee7 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/write.rs @@ -0,0 +1,664 @@ +//! Private typed canonical output. Complete semantic admission belongs to the +//! caller. No public formatter, version dispatch or source edits use this yet. +mod expression; +pub(super) use expression::{Parent, Side}; + +use super::{budget, lower::Field}; +use crate::analysis as a; +use crate::analysis_numeric::Exact; +use crate::{Id, Kind, Layer, Project, SharedBody}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; + +#[derive(Clone, Copy)] +pub(super) struct Limits { + pub bytes: usize, + pub entries: usize, +} +impl Limits { + pub fn source8(caller: crate::language::ParseLimits) -> Self { + let limits = budget::Limits::source8(caller); + Self { + bytes: limits.bytes, + entries: limits.entries, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Error { + pub owner: Option, + pub field: &'static str, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct Usage { + pub bytes: usize, + pub entries: usize, +} + +struct Sink { + buffer: Vec, + written: usize, + maximum: usize, +} +impl Sink { + fn room(&self, bytes: usize) -> std::io::Result<()> { + if bytes > self.maximum.saturating_sub(self.written) { + return Err(std::io::Error::other( + "Source8 writer output byte limit exceeded.", + )); + } + Ok(()) + } +} +impl Write for Sink { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.room(bytes.len())?; + self.buffer.extend_from_slice(bytes); + self.written += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +pub(super) struct Writer<'p> { + project: &'p Project, + parameters: BTreeMap>, + limits: Limits, + entries: usize, + sink: Sink, + owner: Option, + field: &'static str, + failed: Option, +} + +impl<'p> Writer<'p> { + pub fn new(project: &'p Project, limits: Limits) -> Result { + let hard = Limits::source8(crate::language::ParseLimits::UNLIMITED); + if limits.bytes > hard.bytes || limits.entries > hard.entries { + return Err(Error { + owner: None, + field: "writer", + message: "Writer limits exceed the source8 profile.".into(), + }); + } + let mut writer = Self { + project, + parameters: BTreeMap::new(), + limits, + entries: 0, + sink: Sink { + buffer: Vec::new(), + written: 0, + maximum: limits.bytes, + }, + owner: None, + field: "writer", + failed: None, + }; + for definition in project.definitions.values() { + writer.owner = Some(definition.id); + writer.field = "parameters"; + writer.entry()?; + if let a::DefinitionBody::Calculation(calculation) = &definition.body { + let mut parameters = BTreeMap::new(); + let mut names = BTreeSet::new(); + for parameter in &calculation.parameters { + writer.entry()?; + if parameters.contains_key(¶meter.id) + || !names.insert(parameter.name.as_str()) + { + return Err(writer.error("Duplicate scoped parameter identity or name.")); + } + parameters.insert(parameter.id, parameter); + } + writer.parameters.insert(definition.id, parameters); + } + } + Ok(writer) + } + + pub fn usage(&self) -> Usage { + Usage { + bytes: self.sink.written, + entries: self.entries, + } + } + + fn error(&self, message: impl Into) -> Error { + Error { + owner: self.owner, + field: self.field, + message: message.into(), + } + } + fn entry(&mut self) -> Result<(), Error> { + if self.entries == self.limits.entries { + return Err(self.error("Source8 writer entry limit exceeded.")); + } + self.entries += 1; + Ok(()) + } + fn pending(&self, count: usize) -> Result<(), Error> { + if count > self.limits.entries { + return Err(self.error("Source8 writer pending work limit exceeded.")); + } + Ok(()) + } + fn text(&mut self, text: &str) -> Result<(), Error> { + self.sink + .write_all(text.as_bytes()) + .map_err(|cause| self.error(cause.to_string())) + } + fn quote(&mut self, text: &str) -> Result<(), Error> { + // Identical serde_json string escaping to language::quote, streamed + // directly to the checked sink instead of an unbounded scratch String. + self.sink + .room(text.len().saturating_add(2)) + .map_err(|cause| self.error(cause.to_string()))?; + serde_json::to_writer(&mut self.sink, text).map_err(|cause| self.error(cause.to_string())) + } + fn name(&mut self, name: &str) -> Result<(), Error> { + self.sink + .room(name.len()) + .map_err(|cause| self.error(cause.to_string()))?; + if !crate::valid_alias(name) { + return Err(self.error("Invalid alias or parameter name.")); + } + self.text(name) + } + fn exact(&mut self, exact: &Exact) -> Result<(), Error> { + // At most two already-bounded 4096-bit components, never an Expr-sized + // temporary or a second parse/normalization of the stored value. + self.text(&exact.canonical_literal()) + } + fn run( + &mut self, + owner: Id, + field: &'static str, + emit: impl FnOnce(&mut Self) -> Result<(), Error>, + ) -> Result { + if let Some(error) = &self.failed { + return Err(error.clone()); + } + self.owner = Some(owner); + self.field = field; + debug_assert!(self.sink.buffer.is_empty()); + if let Err(error) = emit(self) { + self.sink.buffer.clear(); + self.failed = Some(error.clone()); + return Err(error); + } + Ok(String::from_utf8(std::mem::take(&mut self.sink.buffer)) + .expect("text and JSON string writes are UTF-8")) + } + + fn definition_name( + &self, + reference: a::DefinitionRef, + kind: DefinitionKind, + ) -> Result<&'p str, Error> { + let definition = self + .project + .definitions + .get(&reference.local()) + .ok_or_else(|| self.error("Unknown definition reference."))?; + let matches = matches!( + (&definition.body, kind), + ( + a::DefinitionBody::QuantityKind(_), + DefinitionKind::QuantityKind + ) | (a::DefinitionBody::Unit(_), DefinitionKind::Unit) + | ( + a::DefinitionBody::Calculation(_), + DefinitionKind::Calculation + ) + ); + if !matches { + return Err(self.error("Wrong definition reference domain.")); + } + Ok(&definition.alias) + } + fn definition_ref( + &mut self, + reference: a::DefinitionRef, + kind: DefinitionKind, + ) -> Result<(), Error> { + let alias = self.definition_name(reference, kind)?; + self.text("@")?; + self.name(alias) + } + fn property_name(&self, id: Id) -> Result<&'p str, Error> { + self.project + .elements + .get(&id) + .filter(|value| value.kind() == Kind::EngineeringProperty) + .map(|value| value.alias.as_str()) + .ok_or_else(|| self.error("Unknown property reference or wrong reference domain.")) + } + fn subject(&mut self, subject: a::AnalysisSubject) -> Result<(), Error> { + let alias = match subject { + a::AnalysisSubject::Element(id) => self + .project + .elements + .get(&id) + .filter(|value| !value.kind().is_analysis()) + .map(|value| value.alias.as_str()), + a::AnalysisSubject::Relation(id) => self + .project + .relations + .get(&id) + .map(|value| value.alias.as_str()), + } + .ok_or_else(|| self.error("Unknown architectural subject or wrong reference domain."))?; + self.text("@")?; + self.name(alias) + } + fn optional_subject(&mut self, subject: Option) -> Result<(), Error> { + if let Some(subject) = subject { + self.subject(subject) + } else { + self.text("none") + } + } + fn type_value(&mut self, value: &a::AnalysisType) -> Result<(), Error> { + self.entry()?; + let (scalar, list) = match value { + a::AnalysisType::Scalar(value) => (value, false), + a::AnalysisType::List(value) => (value, true), + }; + if list { + self.text("List(")?; + } + match scalar { + a::ScalarType::Boolean => self.text("Boolean")?, + a::ScalarType::Number => self.text("Number")?, + a::ScalarType::Quantity(kind) => { + self.text("Quantity(")?; + self.definition_ref(*kind, DefinitionKind::QuantityKind)?; + self.text(")")?; + } + } + if list { + self.text(")")?; + } + Ok(()) + } + pub fn value_type(&mut self, owner: Id, value: &a::AnalysisType) -> Result { + self.run(owner, "type", |writer| writer.type_value(value)) + } + fn model_binding( + &self, + owner: Id, + field: Field, + ) -> Result<(&'p a::Binding, Option), Error> { + if field == Field::Body { + if let Some(a::Definition { + body: a::DefinitionBody::Calculation(calculation), + .. + }) = self.project.definitions.get(&owner) + { + return Ok((&calculation.body, Some(owner))); + } + } else if let Some(element) = self.project.elements.get(&owner) { + match (element.shared(), field) { + (Some(SharedBody::EngineeringProperty { base, .. }), Field::Base) => { + return Ok((base, None)); + } + (Some(SharedBody::AnalysisCheck { predicate, .. }), Field::Predicate) => { + return Ok((predicate, None)); + } + (Some(SharedBody::AnalysisCase { overrides, .. }), Field::Override(property)) => { + return overrides + .get(&property) + .map(|binding| (binding, None)) + .ok_or_else(|| self.error("Missing override binding.")); + } + _ => {} + } + } + Err(self.error("Binding field does not belong to the requested owner.")) + } + fn binding_value( + &mut self, + binding: &a::Binding, + calculation: Option, + ) -> Result<(), Error> { + match binding { + a::Binding::Unbound => { + self.entry()?; + self.text("unbound") + } + a::Binding::Expression(expression) => { + self.emit_expression(expression, calculation, Parent::Root) + } + } + } + pub fn binding(&mut self, owner: Id, field: Field) -> Result { + self.run(owner, binding_field(field), |writer| { + let (binding, scope) = writer.model_binding(owner, field)?; + writer.binding_value(binding, scope) + }) + } + pub fn expression( + &mut self, + owner: Id, + field: Field, + expression: &a::Expr, + parent: Parent, + ) -> Result { + self.run(owner, binding_field(field), |writer| { + let (_, scope) = writer.model_binding(owner, field)?; + writer.emit_expression(expression, scope, parent) + }) + } + fn parameter_value(&mut self, calculation: Id, id: a::ParameterId) -> Result<(), Error> { + self.entry()?; + let parameter = *self + .parameters + .get(&calculation) + .and_then(|parameters| parameters.get(&id)) + .ok_or_else(|| self.error("Unknown scoped parameter."))?; + self.text("input ")?; + self.name(¶meter.name)?; + self.text(" id \"")?; + self.text(¶meter.id.0.to_string())?; + self.text("\" : ")?; + self.type_value(¶meter.value_type)?; + self.text(";") + } + pub fn parameter( + &mut self, + calculation: Id, + parameter: a::ParameterId, + ) -> Result { + self.run(calculation, "input", |writer| { + writer.parameter_value(calculation, parameter) + }) + } + fn override_value(&mut self, property: Id, binding: &a::Binding) -> Result<(), Error> { + self.entry()?; + let alias = self.property_name(property)?; + self.text("@")?; + self.name(alias)?; + self.text(" = ")?; + self.binding_value(binding, None)?; + self.text(";") + } + pub fn override_entry(&mut self, case: Id, property: Id) -> Result { + self.run(case, "overrides", |writer| { + let (binding, _) = writer.model_binding(case, Field::Override(property))?; + writer.override_value(property, binding) + }) + } + fn header( + &mut self, + word: &str, + alias: &str, + name: &str, + id: Id, + description: &str, + ) -> Result<(), Error> { + self.entry()?; + self.text(" ")?; + self.text(word)?; + self.text(" ")?; + self.name(alias)?; + self.text(" ")?; + self.quote(name)?; + self.text(" id \"")?; + self.text(&id.to_string())?; + self.text("\" {\n")?; + self.start_field("description")?; + self.quote(description)?; + self.text(";\n") + } + fn start_field(&mut self, name: &'static str) -> Result<(), Error> { + self.field = name; + self.entry()?; + self.text(" ")?; + self.text(name)?; + self.text(" ") + } + pub fn definition(&mut self, id: Id) -> Result { + self.run(id, "definition", |writer| { + let project = writer.project; + let definition = project + .definitions + .get(&id) + .ok_or_else(|| writer.error("Unknown definition."))?; + let word = match &definition.body { + a::DefinitionBody::QuantityKind(_) => "quantity_kind", + a::DefinitionBody::Unit(_) => "unit", + a::DefinitionBody::Calculation(_) => "calculation", + }; + writer.header( + word, + &definition.alias, + &definition.name, + id, + &definition.description, + )?; + match &definition.body { + a::DefinitionBody::QuantityKind(kind) => { + writer.start_field("dimension")?; + writer.text("{")?; + const AXES: [&str; 7] = [ + "length", + "mass", + "time", + "electric_current", + "temperature", + "amount", + "luminous_intensity", + ]; + let mut nonzero = false; + for (axis, exponent) in AXES.into_iter().zip(kind.dimension.exponents()) { + if exponent != 0 { + writer.entry()?; + writer.text(" ")?; + writer.text(axis)?; + writer.text(" ")?; + writer.text(&exponent.to_string())?; + writer.text(";")?; + nonzero = true; + } + } + if nonzero { + writer.text(" ")?; + } + writer.text("};\n")?; + writer.start_field("algebra")?; + match kind.algebra { + a::QuantityAlgebra::Linear => writer.text("Linear")?, + a::QuantityAlgebra::Affine { difference } => { + writer.text("Affine(")?; + writer.definition_ref(difference, DefinitionKind::QuantityKind)?; + writer.text(")")?; + } + } + writer.text(";\n")?; + } + a::DefinitionBody::Unit(unit) => { + writer.start_field("kind")?; + writer.definition_ref(unit.kind, DefinitionKind::QuantityKind)?; + writer.text(";\n")?; + writer.start_field("scale")?; + writer.exact(&unit.scale)?; + writer.text(";\n")?; + writer.start_field("offset")?; + writer.exact(&unit.offset)?; + writer.text(";\n")?; + writer.start_field("symbol")?; + writer.quote(&unit.symbol)?; + writer.text(";\n")?; + } + a::DefinitionBody::Calculation(calculation) => { + for parameter in &calculation.parameters { + writer.field = "input"; + writer.text(" ")?; + writer.parameter_value(id, parameter.id)?; + writer.text("\n")?; + } + writer.start_field("result")?; + writer.type_value(&calculation.result)?; + writer.text(";\n")?; + writer.start_field("body")?; + writer.binding_value(&calculation.body, Some(id))?; + writer.text(";\n")?; + } + } + writer.text(" }\n\n") + }) + } + pub fn analysis_element(&mut self, id: Id) -> Result { + self.run(id, "element", |writer| { + let project = writer.project; + let element = project + .elements + .get(&id) + .ok_or_else(|| writer.error("Unknown analysis element."))?; + let word = match (element.kind(), element.layer()) { + (Kind::EngineeringProperty, Layer::Oa) => "oa_property", + (Kind::EngineeringProperty, Layer::Sa) => "sa_property", + (Kind::AnalysisCheck, Layer::Oa) => "oa_check", + (Kind::AnalysisCheck, Layer::Sa) => "sa_check", + (Kind::AnalysisCase, Layer::Oa) => "oa_analysis", + (Kind::AnalysisCase, Layer::Sa) => "sa_analysis", + _ => return Err(writer.error("Expected an analysis element.")), + }; + if element.parent.is_some() { + return Err(writer.error("Analysis declarations must be roots.")); + } + writer.header( + word, + &element.alias, + &element.name, + id, + &element.description, + )?; + writer.start_field("parent")?; + writer.text("none;\n")?; + match element.shared().expect("analysis kind is a shared body") { + SharedBody::EngineeringProperty { + subject, + value_type, + display_unit, + base, + } => { + writer.start_field("subject")?; + writer.subject(*subject)?; + writer.text(";\n")?; + writer.start_field("type")?; + writer.type_value(value_type)?; + writer.text(";\n")?; + writer.start_field("display_unit")?; + if let Some(unit) = display_unit { + writer.definition_ref(*unit, DefinitionKind::Unit)?; + } else { + writer.text("none")?; + } + writer.text(";\n")?; + writer.start_field("base")?; + writer.binding_value(base, None)?; + writer.text(";\n")?; + } + SharedBody::AnalysisCheck { + subject, + documented_constraint, + predicate, + } => { + writer.start_field("subject")?; + writer.optional_subject(*subject)?; + writer.text(";\n")?; + writer.start_field("documented_constraint")?; + if let Some(id) = documented_constraint { + let alias = project + .elements + .get(id) + .filter(|element| element.kind() == Kind::Constraint) + .map(|element| element.alias.as_str()) + .ok_or_else(|| { + writer.error("Unknown constraint or wrong reference domain.") + })?; + writer.text("@")?; + writer.name(alias)?; + } else { + writer.text("none")?; + } + writer.text(";\n")?; + writer.start_field("predicate")?; + writer.binding_value(predicate, None)?; + writer.text(";\n")?; + } + SharedBody::AnalysisCase { + subject, + base: a::BaselineBase::ModelBaseline, + overrides, + } => { + writer.start_field("subject")?; + writer.optional_subject(*subject)?; + writer.text(";\n")?; + writer.start_field("base")?; + writer.text("ModelBaseline;\n")?; + writer.start_field("overrides")?; + if overrides.is_empty() { + writer.text("{};\n")?; + } else { + writer.text("{\n")?; + for (property, binding) in overrides { + writer.text(" ")?; + writer.override_value(*property, binding)?; + writer.text("\n")?; + } + writer.text(" };\n")?; + } + } + _ => return Err(writer.error("Expected an analysis body.")), + } + writer.text(" }\n\n") + }) + } + pub fn typed_declarations(&mut self) -> Result, Error> { + if let Some(error) = &self.failed { + return Err(error.clone()); + } + let project = self.project; + let mut declarations = Vec::new(); + for id in project.definitions.keys() { + declarations.push((*id, self.definition(*id)?)); + } + for element in project + .elements + .values() + .filter(|element| element.kind().is_analysis()) + { + declarations.push((element.id, self.analysis_element(element.id)?)); + } + Ok(declarations) + } +} + +#[derive(Clone, Copy)] +enum DefinitionKind { + QuantityKind, + Unit, + Calculation, +} + +fn binding_field(field: Field) -> &'static str { + match field { + Field::Body => "body", + Field::Base => "base", + Field::Predicate => "predicate", + Field::Override(_) => "overrides", + _ => "binding", + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/write/expression.rs b/crates/oa-core/src/language/analysis_source/write/expression.rs new file mode 100644 index 00000000..fdf98d9c --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/write/expression.rs @@ -0,0 +1,284 @@ +use super::*; + +#[derive(Clone, Copy)] +pub enum Side { + Left, + Right, +} +#[derive(Clone, Copy)] +pub enum Parent { + Root, + Binary(a::BinaryOp, Side), + Negate, +} + +fn operator(op: a::BinaryOp) -> (&'static str, u8) { + use a::BinaryOp::*; + match op { + Or => (" or ", 1), + And => (" and ", 2), + Equal => (" == ", 3), + NotEqual => (" != ", 3), + Less => (" < ", 4), + LessOrEqual => (" <= ", 4), + Greater => (" > ", 4), + GreaterOrEqual => (" >= ", 4), + Add => (" + ", 5), + Subtract => (" - ", 5), + Multiply => (" * ", 6), + Divide => (" / ", 6), + } +} +fn comparison(op: a::BinaryOp) -> bool { + matches!(operator(op).1, 3 | 4) +} +fn group(expression: &a::Expr, parent: Parent) -> bool { + let a::Expr::Binary { op, .. } = expression else { + return false; + }; + match parent { + Parent::Root => false, + Parent::Negate => true, + Parent::Binary(parent, side) => { + (comparison(*op) && comparison(parent)) + || operator(*op).1 < operator(parent).1 + || (operator(*op).1 == operator(parent).1 && matches!(side, Side::Right)) + } + } +} + +#[derive(Clone, Copy)] +enum Task<'a> { + Expression(&'a a::Expr, Parent, usize), + Text(&'static str), + Alias(&'a str), + Literal(&'a Exact), + Exponent(i8), +} +fn schedule<'a>( + writer: &Writer<'_>, + tasks: &mut Vec>, + ordered: &[Task<'a>], +) -> Result<(), Error> { + writer.pending(tasks.len().saturating_add(ordered.len()))?; + tasks.extend(ordered.iter().rev().copied()); + Ok(()) +} + +impl Writer<'_> { + pub(super) fn emit_expression( + &mut self, + expression: &a::Expr, + calculation: Option, + parent: Parent, + ) -> Result<(), Error> { + self.pending(1)?; + let mut tasks = vec![Task::Expression(expression, parent, 1)]; + while let Some(task) = tasks.pop() { + match task { + Task::Text(text) => self.text(text)?, + Task::Alias(name) => { + self.entry()?; + self.name(name)?; + } + Task::Literal(exact) => self.exact(exact)?, + Task::Exponent(exponent) => self.text(&exponent.to_string())?, + Task::Expression(expression, parent, depth) => { + self.entry()?; + if depth > a::MAX_EXPRESSION_DEPTH { + return Err(self.error("Expression depth exceeds128.")); + } + if group(expression, parent) { + self.text("(")?; + schedule(self, &mut tasks, &[Task::Text(")")])?; + } + use Task::{Alias, Exponent, Expression as Child, Literal, Text}; + use a::Expr as E; + match expression { + E::Boolean(value) => self.text(if *value { "true" } else { "false" })?, + E::Number(value) => self.exact(value)?, + E::Quantity { magnitude, unit } => { + let alias = self.definition_name(*unit, DefinitionKind::Unit)?; + schedule( + self, + &mut tasks, + &[Literal(magnitude), Text(" [@"), Alias(alias), Text("]")], + )?; + } + E::Property(id) => { + let alias = self.property_name(*id)?; + schedule(self, &mut tasks, &[Text("@"), Alias(alias)])?; + } + E::Parameter(parameter) => { + let calculation = calculation.ok_or_else(|| { + self.error("Parameter use requires an owning calculation.") + })?; + let parameter = *self + .parameters + .get(&calculation) + .and_then(|parameters| parameters.get(parameter)) + .ok_or_else(|| { + self.error("Unknown parameter in owning calculation.") + })?; + schedule(self, &mut tasks, &[Text("$"), Alias(¶meter.name)])?; + } + E::List(items) => { + if items.len() > a::MAX_LIST_LENGTH { + return Err(self.error("List exceeds1024 items.")); + } + self.pending( + tasks + .len() + .saturating_add(items.len().saturating_mul(2)) + .saturating_add(1), + )?; + self.text("[")?; + tasks.push(Text("]")); + for (index, item) in items.iter().enumerate().rev() { + tasks.push(Child(item, Parent::Root, depth + 1)); + if index != 0 { + tasks.push(Text(", ")); + } + } + } + E::Unary { op, operand } => match op { + a::UnaryOp::Negate => schedule( + self, + &mut tasks, + &[Text("- "), Child(operand, Parent::Negate, depth + 1)], + )?, + a::UnaryOp::Absolute | a::UnaryOp::Not => schedule( + self, + &mut tasks, + &[ + Text(if *op == a::UnaryOp::Absolute { + "abs(" + } else { + "not(" + }), + Child(operand, Parent::Root, depth + 1), + Text(")"), + ], + )?, + }, + E::Binary { op, left, right } => schedule( + self, + &mut tasks, + &[ + Child(left, Parent::Binary(*op, Side::Left), depth + 1), + Text(operator(*op).0), + Child(right, Parent::Binary(*op, Side::Right), depth + 1), + ], + )?, + E::If { + condition, + when_true, + when_false, + } => schedule( + self, + &mut tasks, + &[ + Text("if("), + Child(condition, Parent::Root, depth + 1), + Text(", "), + Child(when_true, Parent::Root, depth + 1), + Text(", "), + Child(when_false, Parent::Root, depth + 1), + Text(")"), + ], + )?, + E::Call { + definition, + arguments, + } => { + let alias = + self.definition_name(*definition, DefinitionKind::Calculation)?; + let project = self.project; + let a::DefinitionBody::Calculation(callee) = + &project.definitions[&definition.local()].body + else { + unreachable!("checked calculation domain") + }; + if arguments.len() != callee.parameters.len() + || callee + .parameters + .iter() + .any(|parameter| !arguments.contains_key(¶meter.id)) + { + return Err(self.error( + "Call argument IDs must match the called signature exactly.", + )); + } + self.pending( + tasks + .len() + .saturating_add(arguments.len().saturating_mul(4)) + .saturating_add(1), + )?; + self.entry()?; + self.text("@")?; + self.name(alias)?; + self.text("(")?; + tasks.push(Text(")")); + for (index, parameter) in callee.parameters.iter().enumerate().rev() { + tasks.push(Child( + &arguments[¶meter.id], + Parent::Root, + depth + 1, + )); + tasks.push(Text(": ")); + tasks.push(Alias(¶meter.name)); + if index != 0 { + tasks.push(Text(", ")); + } + } + } + E::Sum { values, initial } => schedule( + self, + &mut tasks, + &[ + Text("sum("), + Child(values, Parent::Root, depth + 1), + Text(", "), + Child(initial, Parent::Root, depth + 1), + Text(")"), + ], + )?, + E::Power { base, exponent } => { + if !(-16..=16).contains(exponent) { + return Err(self.error("Power exponent exceeds -16..16.")); + } + schedule( + self, + &mut tasks, + &[ + Text("power("), + Child(base, Parent::Root, depth + 1), + Text(", "), + Exponent(*exponent), + Text(")"), + ], + )?; + } + E::AsKind { kind, value } => { + let alias = + self.definition_name(*kind, DefinitionKind::QuantityKind)?; + schedule( + self, + &mut tasks, + &[ + Text("as_kind(@"), + Alias(alias), + Text(", "), + Child(value, Parent::Root, depth + 1), + Text(")"), + ], + )?; + } + } + } + } + } + Ok(()) + } +} diff --git a/crates/oa-core/src/language/analysis_source/write/tests.rs b/crates/oa-core/src/language/analysis_source/write/tests.rs new file mode 100644 index 00000000..7ddb18d2 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/write/tests.rs @@ -0,0 +1,910 @@ +use super::super::{cursor::Cursor, declarations, lex, lower, model_oracle}; +use super::*; +use crate::language::ParseLimits; +use a::{ + AnalysisType, BinaryOp, Binding, DefinitionBody, DefinitionRef, Expr, ParameterId, ScalarType, + UnaryOp, +}; + +fn limits() -> Limits { + Limits::source8(ParseLimits::UNLIMITED) +} +fn writer(project: &Project) -> Writer<'_> { + Writer::new(project, limits()).unwrap() +} +fn id(text: &str) -> Id { + Id::parse_str(text).unwrap() +} +fn object(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn number(value: i64) -> Expr { + Expr::Number(Exact::from_integer(value)) +} +fn unary(op: UnaryOp, value: Expr) -> Expr { + Expr::Unary { + op, + operand: Box::new(value), + } +} +fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} +fn quantity(value: i64, unit: Id) -> Expr { + Expr::Quantity { + magnitude: Exact::from_integer(value), + unit: DefinitionRef::Local(unit), + } +} +fn budget() -> budget::Budget { + budget::Budget::new(budget::Limits::source8(ParseLimits::UNLIMITED)).unwrap() +} +fn unit(source: &str, budget: &mut budget::Budget) -> Result { + let tokens = lex::tokens(source, budget)?; + let mut cursor = Cursor::new(source, &tokens, budget); + let mut parsed = Vec::new(); + while cursor.cursor < tokens.len() { + parsed.push(declarations::parse(&mut cursor, 8, None)?); + } + Ok(lower::Unit { + file: 1, + declarations: parsed, + }) +} +fn merge(mut project: Project, lowered: lower::Lowered) -> Project { + project.definitions.extend(lowered.definitions); + project.elements.extend(lowered.elements); + project +} +fn parse_typed(project: Project, source: &str) -> Project { + let mut budget = budget(); + let unit = unit(source, &mut budget).unwrap(); + let lowered = lower::lower(&project, &[unit], &budget).unwrap(); + merge(project, lowered) +} +fn skeleton(project: &Project) -> Project { + let mut project = project.clone(); + project.definitions.clear(); + project + .elements + .retain(|_, element| !element.kind().is_analysis()); + project +} +fn canonical(project: &Project) -> String { + writer(project) + .typed_declarations() + .unwrap() + .into_iter() + .map(|(_, text)| text) + .collect() +} +fn read_expression(project: &Project, source: &str) -> Expr { + // A staged syntax/lowering wrapper, not a whole public source8 parser. + let source = format!( + r#"calculation writer_probe "Probe" id "00000000-0000-0000-0000-000000000099" {{ + input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration); + input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration); + result Number; body {source}; + }}"# + ); + let mut parsed = parse_typed(project.clone(), &source); + let definition = parsed.definitions.remove(&Id::from_u128(0x99)).unwrap(); + let DefinitionBody::Calculation(calculation) = definition.body else { + panic!("calculation") + }; + let Binding::Expression(expression) = calculation.body else { + panic!("expression") + }; + expression +} + +#[test] +fn typed_oa_sa_canonical_roundtrips_match_independent_projects() { + for (layer, ordinary_source) in [ + ( + Layer::Oa, + include_str!("../lower/fixtures/ordinary-oa.arcadia"), + ), + ( + Layer::Sa, + include_str!("../lower/fixtures/ordinary-sa.arcadia"), + ), + ] { + let expected = model_oracle::project(layer); + for (alias, name) in model_oracle::NAMES { + let id = object(&expected, alias); + assert_eq!( + expected + .definitions + .get(&id) + .map(|value| value.name.as_str()) + .or_else(|| expected.elements.get(&id).map(|value| value.name.as_str())) + .unwrap(), + name + ); + } + let text = canonical(&expected); + let actual = parse_typed(crate::language::parse(ordinary_source).unwrap(), &text); + assert_eq!(actual, expected); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); + assert_eq!(canonical(&actual), text); + assert!( + !text.contains("engineering_property") + && !text.contains("analysis_case") + && !text.contains("analysis_check") + ); + let prefix = if layer == Layer::Oa { "oa" } else { "sa" }; + for word in ["property", "check", "analysis"] { + assert!(text.contains(&format!("{prefix}_{word} "))); + } + } + assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err()); +} + +#[test] +fn typed_declaration_order_and_required_empty_values_are_canonical() { + let mut project = model_oracle::project(Layer::Oa); + let duration = object(&project, "duration"); + let total = object(&project, "total_time"); + if let Some(SharedBody::EngineeringProperty { display_unit, .. }) = + project.elements.get_mut(&total).unwrap().body.shared_mut() + { + *display_unit = None; + } + let empty_case = object(&project, "unmeasured"); + if let Some(SharedBody::AnalysisCase { overrides, .. }) = project + .elements + .get_mut(&empty_case) + .unwrap() + .body + .shared_mut() + { + overrides.clear(); + } + let degraded = object(&project, "degraded"); + let dispatch = object(&project, "dispatch_time"); + let second = object(&project, "second"); + if let Some(SharedBody::AnalysisCase { overrides, .. }) = project + .elements + .get_mut(°raded) + .unwrap() + .body + .shared_mut() + { + overrides.insert(dispatch, Binding::Expression(quantity(1, second))); + } + let definitions = [ + ( + 100, + "dimensionless", + DefinitionBody::QuantityKind(a::QuantityKindDefinition { + dimension: crate::analysis_numeric::Dimension::ZERO, + algebra: a::QuantityAlgebra::Linear, + }), + ), + ( + 101, + "point", + DefinitionBody::QuantityKind(a::QuantityKindDefinition { + dimension: crate::analysis_numeric::Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(), + algebra: a::QuantityAlgebra::Affine { + difference: DefinitionRef::Local(duration), + }, + }), + ), + ( + 102, + "point_unit", + DefinitionBody::Unit(a::UnitDefinition { + kind: DefinitionRef::Local(Id::from_u128(101)), + scale: Exact::from_integer(1), + offset: Exact::from_integer(-2), + symbol: "\"café\"\n雪".into(), + }), + ), + ( + 103, + "unused", + DefinitionBody::Calculation(a::CalculationDefinition { + parameters: Vec::new(), + result: AnalysisType::List(ScalarType::Number), + body: Binding::Unbound, + }), + ), + ]; + for (id, alias, body) in definitions { + let id = Id::from_u128(id); + project.definitions.insert( + id, + a::Definition { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + body, + }, + ); + } + let mut writer = writer(&project); + let declarations = writer.typed_declarations().unwrap(); + let ids: Vec<_> = declarations.iter().map(|(id, _)| *id).collect(); + let expected_ids: Vec<_> = project + .definitions + .keys() + .copied() + .chain( + project + .elements + .values() + .filter(|element| element.kind().is_analysis()) + .map(|element| element.id), + ) + .collect(); + assert_eq!(ids, expected_ids); + let text: String = declarations.into_iter().map(|(_, text)| text).collect(); + assert!(text.find("@receive_time =").unwrap() < text.find("@dispatch_time =").unwrap()); + for expected in [ + "dimension {};", + "dimension { time 1; };", + "algebra Affine(@duration);", + "offset ratio(-2, 1);", + "result List(Number);", + "body unbound;", + "display_unit none;", + "overrides {};", + "parent none;", + "subject none;", + "documented_constraint none;", + ] { + assert!(text.contains(expected), "{expected}"); + } + assert_eq!( + text.matches("\" id \"").count(), + project.definitions.len() + 7 + ); + let actual = parse_typed(skeleton(&project), &text); + assert_eq!(actual, project); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); + assert_eq!(canonical(&actual), text); + assert_eq!( + self::writer(&project) + .override_entry(empty_case, total) + .unwrap_err() + .field, + "overrides" + ); +} + +#[test] +fn all_expr_variants_and_precedence_preserve_exact_trees() { + let mut project = model_oracle::project(Layer::Oa); + let second = object(&project, "second"); + let duration = object(&project, "duration"); + let property = object(&project, "receive_time"); + let total = object(&project, "total_time"); + let combine = object(&project, "combine"); + let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976")); + let numeric = AnalysisType::Scalar(ScalarType::Number); + let boolean = AnalysisType::Scalar(ScalarType::Boolean); + let duration_type = AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration))); + let cases = [ + (Expr::Boolean(true), "true", boolean.clone()), + (number(-1), "ratio(-1, 1)", numeric.clone()), + ( + quantity(-1, second), + "ratio(-1, 1) [@second]", + duration_type.clone(), + ), + ( + Expr::Property(property), + "@receive_time", + duration_type.clone(), + ), + (Expr::Parameter(a), "$a", duration_type.clone()), + ( + Expr::List(vec![number(1), number(2)]), + "[1, 2]", + AnalysisType::List(ScalarType::Number), + ), + ( + unary(UnaryOp::Absolute, unary(UnaryOp::Negate, number(2))), + "abs(- 2)", + numeric.clone(), + ), + ( + binary(BinaryOp::Add, number(1), number(2)), + "1 + 2", + numeric.clone(), + ), + ( + Expr::If { + condition: Box::new(Expr::Boolean(true)), + when_true: Box::new(number(1)), + when_false: Box::new(number(2)), + }, + "if(true, 1, 2)", + numeric.clone(), + ), + ( + Expr::Call { + definition: DefinitionRef::Local(combine), + arguments: BTreeMap::from([(b, quantity(2, second)), (a, quantity(1, second))]), + }, + "@combine(a: 1 [@second], b: 2 [@second])", + duration_type.clone(), + ), + ( + Expr::Sum { + values: Box::new(Expr::List(vec![number(1), number(2)])), + initial: Box::new(number(0)), + }, + "sum([1, 2], 0)", + numeric.clone(), + ), + ( + Expr::Power { + base: Box::new(number(2)), + exponent: -2, + }, + "power(2, -2)", + numeric.clone(), + ), + ( + Expr::AsKind { + kind: DefinitionRef::Local(duration), + value: Box::new(binary( + BinaryOp::Divide, + Expr::Power { + base: Box::new(quantity(1, second)), + exponent: 2, + }, + quantity(1, second), + )), + }, + "as_kind(@duration, power(1 [@second], 2) / 1 [@second])", + duration_type, + ), + ]; + project.elements.remove(&object(&project, "budget")); + for (expected, golden, value_type) in cases { + let parameter = matches!(expected, Expr::Parameter(_)); + let owner = if parameter { combine } else { total }; + let field = if parameter { Field::Body } else { Field::Base }; + let text = writer(&project) + .expression(owner, field, &expected, Parent::Root) + .unwrap(); + assert_eq!(text, golden); + let actual = read_expression(&project, &text); + assert_eq!(actual, expected); + let mut candidate = project.clone(); + if parameter { + let DefinitionBody::Calculation(calculation) = + &mut candidate.definitions.get_mut(&combine).unwrap().body + else { + panic!() + }; + calculation.body = Binding::Expression(actual); + } else { + let Some(SharedBody::EngineeringProperty { + base, + value_type: ty, + display_unit, + .. + }) = candidate + .elements + .get_mut(&total) + .unwrap() + .body + .shared_mut() + else { + panic!() + }; + *base = Binding::Expression(actual); + *ty = value_type; + *display_unit = None; + } + a::admit(&candidate, a::AdmissionLimits::REFERENCE).unwrap(); + } + let cases = [ + (unary(UnaryOp::Negate, number(1)), "- 1"), + (unary(UnaryOp::Negate, number(-1)), "- ratio(-1, 1)"), + ( + unary(UnaryOp::Negate, unary(UnaryOp::Negate, number(1))), + "- - 1", + ), + ( + unary(UnaryOp::Negate, binary(BinaryOp::Add, number(1), number(2))), + "- (1 + 2)", + ), + (unary(UnaryOp::Not, Expr::Boolean(true)), "not(true)"), + ( + binary( + BinaryOp::Subtract, + number(1), + binary(BinaryOp::Subtract, number(2), number(3)), + ), + "1 - (2 - 3)", + ), + ( + binary( + BinaryOp::Subtract, + binary(BinaryOp::Subtract, number(1), number(2)), + number(3), + ), + "1 - 2 - 3", + ), + ( + binary( + BinaryOp::Add, + number(1), + binary(BinaryOp::Add, number(2), number(3)), + ), + "1 + (2 + 3)", + ), + ( + binary( + BinaryOp::Multiply, + binary(BinaryOp::Add, number(1), number(2)), + number(3), + ), + "(1 + 2) * 3", + ), + ( + binary( + BinaryOp::Equal, + binary(BinaryOp::Less, number(1), number(2)), + Expr::Boolean(true), + ), + "(1 < 2) == true", + ), + ( + binary( + BinaryOp::Equal, + binary(BinaryOp::Equal, Expr::Boolean(true), Expr::Boolean(false)), + Expr::Boolean(true), + ), + "(true == false) == true", + ), + ( + binary( + BinaryOp::Equal, + Expr::Boolean(true), + binary(BinaryOp::Less, number(1), number(2)), + ), + "true == (1 < 2)", + ), + (Expr::List(Vec::new()), "[]"), + ]; + for (expected, golden) in cases { + let text = writer(&project) + .expression(total, Field::Base, &expected, Parent::Root) + .unwrap(); + assert_eq!(text, golden); + assert_eq!(read_expression(&project, &text), expected); + } +} + +#[test] +fn canonical_calls_use_callee_parameter_order_and_qualified_names() { + let mut project = model_oracle::project(Layer::Oa); + let combine = object(&project, "combine"); + let total = object(&project, "total_time"); + let duration = object(&project, "duration"); + let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976")); + let DefinitionBody::Calculation(calculation) = + &mut project.definitions.get_mut(&combine).unwrap().body + else { + panic!() + }; + calculation.parameters.reverse(); + let other = Id::from_u128(120); + project.definitions.insert( + other, + a::Definition { + id: other, + alias: "other".into(), + name: "Other".into(), + description: String::new(), + body: DefinitionBody::Calculation(a::CalculationDefinition { + parameters: vec![a::Parameter { + id: a, + name: "first".into(), + value_type: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local( + duration, + ))), + }], + result: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration))), + body: Binding::Expression(Expr::Parameter(a)), + }), + }, + ); + project.definitions.insert( + a.0, + a::Definition { + id: a.0, + alias: "collision".into(), + name: "Collision".into(), + description: String::new(), + body: DefinitionBody::QuantityKind(a::QuantityKindDefinition { + dimension: crate::analysis_numeric::Dimension::ZERO, + algebra: a::QuantityAlgebra::Linear, + }), + }, + ); + let mut out = writer(&project); + let text = out.binding(total, Field::Base).unwrap(); + assert!(text.starts_with("@combine(b: @dispatch_time, a: @receive_time)")); + let call = Expr::Call { + definition: DefinitionRef::Local(other), + arguments: BTreeMap::from([(a, Expr::Parameter(a))]), + }; + assert_eq!( + out.expression(combine, Field::Body, &call, Parent::Root) + .unwrap(), + "@other(first: $a)" + ); + assert!(out.parameter(combine, a).unwrap().starts_with("input a id")); + assert!( + out.parameter(other, a) + .unwrap() + .starts_with("input first id") + ); + assert!(out.parameter(combine, b).unwrap().starts_with("input b id")); + let text = canonical(&project); + let actual = parse_typed(skeleton(&project), &text); + assert_eq!(actual, project); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); +} + +#[test] +fn subtree_output_respects_parent_context_and_retained_delimiters() { + let project = model_oracle::project(Layer::Oa); + let owner = object(&project, "total_time"); + for replacement in [ + binary(BinaryOp::Add, number(1), number(2)), + number(-1), + unary(UnaryOp::Negate, number(1)), + ] { + let text = writer(&project) + .expression(owner, Field::Base, &replacement, Parent::Negate) + .unwrap(); + let source = format!("-{text}"); // replace the entire original operand span `(1)` in `-(1)` + assert_eq!( + read_expression(&project, &source), + unary(UnaryOp::Negate, replacement) + ); + } + let value = binary(BinaryOp::Subtract, number(2), number(3)); + let text = writer(&project) + .expression( + owner, + Field::Base, + &value, + Parent::Binary(BinaryOp::Subtract, Side::Right), + ) + .unwrap(); + assert_eq!(text, "(2 - 3)"); + assert_eq!( + read_expression(&project, &format!("1 - {text}")), + binary(BinaryOp::Subtract, number(1), value) + ); + assert_eq!( + writer(&project) + .value_type(owner, &AnalysisType::List(ScalarType::Boolean)) + .unwrap(), + "List(Boolean)" + ); + let case = object(&project, "unmeasured"); + let property = object(&project, "receive_time"); + assert_eq!( + writer(&project).override_entry(case, property).unwrap(), + "@receive_time = unbound;" + ); +} + +#[test] +fn canonical_strings_and_exact_literals_use_existing_encodings() { + let mut project = model_oracle::project(Layer::Oa); + let second = object(&project, "second"); + let text = "\"\\\n\r\t\u{0000} café 雪"; + project.definitions.get_mut(&second).unwrap().name = text.into(); + project.definitions.get_mut(&second).unwrap().description = text.into(); + let DefinitionBody::Unit(unit) = &mut project.definitions.get_mut(&second).unwrap().body else { + panic!() + }; + unit.symbol = text.into(); + let output = writer(&project).definition(second).unwrap(); + assert_eq!(output.matches(&crate::language::quote(text)).count(), 3); + let actual = parse_typed(skeleton(&project), &canonical(&project)); + assert_eq!(actual, project); + let owner = object(&project, "total_time"); + for source in ["0", "ratio(-1, 1)", "ratio(3, 2)", "1e100"] { + let expression = read_expression(&project, source); + let Expr::Number(value) = &expression else { + panic!() + }; + assert_eq!( + writer(&project) + .expression(owner, Field::Base, &expression, Parent::Root) + .unwrap(), + value.canonical_literal() + ); + } +} + +#[test] +fn writer_limits_are_aggregate_exact_and_latched_without_partial_results() { + let project = model_oracle::project(Layer::Oa); + let owner = object(&project, "total_time"); + let mut one = Writer::new( + &project, + Limits { + bytes: 1, + ..limits() + }, + ) + .unwrap(); + assert_eq!( + one.expression(owner, Field::Base, &number(1), Parent::Root) + .unwrap(), + "1" + ); + let failure = one + .expression(owner, Field::Base, &number(2), Parent::Root) + .unwrap_err(); + assert_eq!( + failure, + one.expression(owner, Field::Base, &number(0), Parent::Root) + .unwrap_err() + ); + assert_eq!(one.usage().bytes, 1); + let mut measured = writer(&project); + measured + .expression(owner, Field::Base, &number(1), Parent::Root) + .unwrap(); + let usage = measured.usage(); + for (maximum, pass) in [(usage.entries, true), (usage.entries - 1, false)] { + let mut bounded = Writer::new( + &project, + Limits { + entries: maximum, + ..limits() + }, + ) + .unwrap(); + assert_eq!( + bounded + .expression(owner, Field::Base, &number(1), Parent::Root) + .is_ok(), + pass + ); + } + let output = canonical(&project); + let mut exact = Writer::new( + &project, + Limits { + bytes: output.len(), + ..limits() + }, + ) + .unwrap(); + assert_eq!(exact.typed_declarations().unwrap().len(), 11); + let mut short = Writer::new( + &project, + Limits { + bytes: output.len() - 1, + ..limits() + }, + ) + .unwrap(); + assert!(short.typed_declarations().is_err()); + assert_eq!(canonical(&project), output); + let mut quoted = project.clone(); + let unit_id = object("ed, "second"); + quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into(); + let text = writer("ed).definition(unit_id).unwrap(); + for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] { + assert_eq!( + Writer::new( + "ed, + Limits { + bytes: maximum, + ..limits() + } + ) + .unwrap() + .definition(unit_id) + .is_ok(), + pass + ); + } + assert!( + Writer::new( + &project, + Limits { + bytes: usize::MAX, + ..limits() + } + ) + .is_err() + ); +} + +#[test] +fn writer_lookup_scope_shape_and_depth_failures_are_model_located() { + let project = model_oracle::project(Layer::Oa); + let owner = object(&project, "total_time"); + let combine = object(&project, "combine"); + let duration = object(&project, "duration"); + let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let cases = [ + Expr::Property(duration), + Expr::Property(Id::nil()), + Expr::Quantity { + magnitude: Exact::from_integer(1), + unit: DefinitionRef::Local(duration), + }, + Expr::Parameter(a), + Expr::Call { + definition: DefinitionRef::Local(duration), + arguments: BTreeMap::new(), + }, + Expr::Call { + definition: DefinitionRef::Local(combine), + arguments: BTreeMap::new(), + }, + Expr::Call { + definition: DefinitionRef::Local(combine), + arguments: BTreeMap::from([(ParameterId(Id::nil()), number(1)), (a, number(2))]), + }, + Expr::Power { + base: Box::new(number(1)), + exponent: 17, + }, + Expr::List((0..1025).map(|_| number(1)).collect()), + ]; + for expression in cases { + let error = writer(&project) + .expression(owner, Field::Base, &expression, Parent::Root) + .unwrap_err(); + assert_eq!(error.owner, Some(owner)); + assert_eq!(error.field, "base"); + } + assert!( + writer(&project) + .expression( + combine, + Field::Body, + &Expr::Parameter(ParameterId(Id::nil())), + Parent::Root + ) + .is_err() + ); + assert!(writer(&project).binding(owner, Field::Body).is_err()); + assert!( + writer(&project) + .analysis_element(object(&project, "respond")) + .is_err() + ); + let mut deep = number(-1); + for _ in 0..128 { + deep = unary(UnaryOp::Negate, deep); + } + assert!( + writer(&project) + .expression(owner, Field::Base, &deep, Parent::Root) + .unwrap_err() + .message + .contains("depth") + ); +} + +#[test] +fn negate_override_roundtrip_preserves_depth128_within_frame256() { + let original = model_oracle::project(Layer::Oa); + let prefix = format!("{}ratio(-1, 1)", "- ".repeat(127)); + let grouped = format!("{}ratio(-1, 1){}", "-(".repeat(127), ")".repeat(127)); + let property = r#"oa_property measure "Measure" id "00000000-0000-0000-0000-000000000130" { subject @respond; type Number; base unbound; }"#; + let case = |expression: &str| { + format!( + r#"oa_analysis measured "Measured" id "00000000-0000-0000-0000-000000000131" {{ base ModelBaseline; overrides {{ @measure = {expression}; }}; }}"# + ) + }; + let calculation = |expression: &str| { + format!( + r#"calculation measured_calc "Measured calculation" id "00000000-0000-0000-0000-000000000132" {{ result Number; body {expression}; }}"# + ) + }; + for (source, frames) in [ + (case(&prefix), 130), + (calculation(&prefix), 129), + (calculation(&grouped), 256), + ] { + let mut budget = budget(); + unit(&source, &mut budget).unwrap(); + assert_eq!(budget.usage.deepest_frame, frames); + } + let mut failed_budget = budget(); + let error = unit(&case(&grouped), &mut failed_budget).err().unwrap(); + assert!(error.message.contains("syntax frames")); + assert_eq!(failed_budget.usage.deepest_frame, 256); + let project = parse_typed(original, &format!("{property}\n{}", case(&prefix))); + a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap(); + let case_id = Id::from_u128(0x131); + let property_id = Id::from_u128(0x130); + let Some(SharedBody::AnalysisCase { overrides, .. }) = project.elements[&case_id].shared() + else { + panic!() + }; + let Binding::Expression(root) = &overrides[&property_id] else { + panic!() + }; + let mut current = root; + let mut negates = 0; + while let Expr::Unary { + op: UnaryOp::Negate, + operand, + } = current + { + current = operand; + negates += 1; + } + assert_eq!(negates, 127); + assert_eq!(current, &number(-1)); + let text = writer(&project).analysis_element(case_id).unwrap(); + assert!(text.contains(&prefix)); + let mut budget = budget(); + unit(&text, &mut budget).unwrap(); + assert_eq!(budget.usage.deepest_frame, 130); + let mut before_case = project.clone(); + before_case.elements.remove(&case_id); + let actual = parse_typed(before_case, &text); + assert_eq!(actual, project); + assert_eq!(writer(&actual).analysis_element(case_id).unwrap(), text); +} + +#[test] +fn iterative_writer_preserves_width1024_and_empty_call_topology() { + let mut project = model_oracle::project(Layer::Oa); + let owner = object(&project, "total_time"); + let expected = Expr::List((0..1024).map(|_| number(1)).collect()); + let text = writer(&project) + .expression(owner, Field::Base, &expected, Parent::Root) + .unwrap(); + assert_eq!(read_expression(&project, &text), expected); + let empty = Id::from_u128(140); + project.definitions.insert( + empty, + a::Definition { + id: empty, + alias: "empty".into(), + name: "Empty".into(), + description: String::new(), + body: DefinitionBody::Calculation(a::CalculationDefinition { + parameters: Vec::new(), + result: AnalysisType::Scalar(ScalarType::Number), + body: Binding::Unbound, + }), + }, + ); + let call = Expr::Call { + definition: DefinitionRef::Local(empty), + arguments: BTreeMap::new(), + }; + let text = writer(&project) + .expression(owner, Field::Base, &call, Parent::Root) + .unwrap(); + assert_eq!(text, "@empty()"); + assert_eq!(read_expression(&project, &text), call); +}