Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu a578ac98a0 Fix mixed parser Clippy guards and sync shared agent instructions 2026-09-23 06:29:06 -07:00
sorokin.oleg.edu eeb1069cfc Fix ordinary semantic parser initializer under test configuration 2026-09-23 05:38:04 -07:00
sorokin.oleg.edu 754a9b4b7c Retain mixed parse policy and check aggregate budgets before traversal 2026-09-23 04:42:32 -07:00
sorokin.oleg.edu 0fc0826435 Add private mixed bundle syntax witness over shared parser and traversal 2026-09-23 01:25:00 -07:00
sorokin.oleg.edu 10d7f3340d test(core): preserve standalone no-op budget boundary 2026-09-23 00:18:21 -07:00
sorokin.oleg.edu 75bc285386 fix(core): retain standalone rename budget preflight semantics 2026-09-23 00:15:15 -07:00
sorokin.oleg.edu ded72c2339 feat(core): export private source-bound rename batches 2026-09-23 00:13:36 -07:00
sorokin.oleg.edu 1b1c3e3abb fix(core): iteratively clean rejected typed syntax depth 2026-09-22 12:00:06 -07:00
sorokin.oleg.edu 90fae14818 Make typed source index paths iterative and strengthen adversarial controls 2026-09-13 05:27:02 -07:00
sorokin.oleg.edu 1f8283236f Stage qualified typed source index and simultaneous rename plans 2026-09-13 04:49:16 -07:00
sorokin.oleg.edu f1d79df497 Stage bounded typed source8 canonical and subtree writers 2026-09-13 00:34:59 -07:00
sorokin.oleg.edu bed5b0e832 Record the bounded canonical Negate writer correction 2026-09-13 00:10:35 -07:00
sorokin.oleg.edu a2a28f8d23 Use an anonymous dimension in the AsKind lowering oracle 2026-09-12 23:50:33 -07:00
sorokin.oleg.edu 472fe7a433 Use accepted line comments in source8 call-site fixture 2026-09-12 23:03:24 -07:00
sorokin.oleg.edu 6de3b53103 Stage bounded source8 typed resolution and expression lowering 2026-09-12 22:56:11 -07:00
sorokin.oleg.edu b2c970b467 fix(source): count typed braces in shared syntax frames 2026-09-12 22:06:04 -07:00
sorokin.oleg.edu 021bdc5a92 feat(source): stage typed declaration syntax on shared raw tokens 2026-09-12 21:59:20 -07:00
sorokin.oleg.edu 6216cff4cf Construct the frozen source3 migration envelope explicitly 2026-09-12 21:38:14 -07:00
sorokin.oleg.edu 9ff6e7cf7a refactor(source): name private analysis expression kind explicitly 2026-09-12 21:31:40 -07:00
sorokin.oleg.edu 2c515cf3bd Strengthen staged expression oracles and preserve source8 evidence 2026-09-12 20:48:41 -07:00
sorokin.oleg.edu e8a18a7cf9 Merge remote-tracking branch 'refs/remotes/gitea/analysis-core-956atf-20260911' into codex/analysis-core-20260911 2026-09-12 20:40:18 -07:00
sorokin.oleg.edu 02b9d5ce4c Stage bounded source8 expression syntax and raw token spans 2026-09-12 20:37:12 -07:00
sorokin.oleg.edu 112e0d910b Reject unreopenable source before checked save stages files 2026-09-12 20:22:15 -07:00
sorokin.oleg.edu bff8014c1f Sync agent instructions from main 2026-09-12 13:43:06 +03:00
sorokin.oleg.edu df3f4072dd Sync agent instructions from main 2026-09-12 13:39:49 +03:00
sorokin.oleg.edu 1513aa2794 Sync agent instructions from main 2026-09-12 13:15:16 +03:00
sorokin.oleg.edu 3174e24eea Sync agent instructions from main 2026-09-11 19:51:22 +03:00
Codex 847b1caa7e feat(analysis): stage bounded JSON codec and iterative expression ownership 2026-09-11 09:14:16 -07:00
Codex cdebb49d89 fix(analysis): bound type-checker continuations and admission work 2026-09-11 08:09:03 -07:00
Codex b96ff3935a WIP checkpoint schema8 analysis core before user reboot 2026-09-11 06:46:52 -07:00
Codex 7d14bd0dd1 Add exact analysis numbers units and bounded request work 2026-09-11 05:50:45 -07:00
Codex dc0f2e12f3 Import corrected creation oracle and record exact SA action history 2026-09-11 05:41:06 -07:00
sorokin.oleg.edu bc311bc076 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:27:16 -07:00
Codex a34558d3be Record OA native history and ratified analysis library decisions 2026-09-11 05:21:37 -07:00
Codex 2cc4d115d0 Specify baseline analysis and verified library contract compatibility 2026-09-11 04:48:44 -07:00
sorokin.oleg.edu 1516949567 Add named behavior creation and verified textual authoring examples 2026-09-11 04:26:56 -07:00
Codex c4e1580bb0 Exercise bounded reference chooser with mounted input tests 2026-09-11 02:29:15 -07:00
2441 changed files with 31378 additions and 471498 deletions
+10 -47
View File
@@ -22,65 +22,28 @@ harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
harness/release-native-child-continuation-review-20260911.md -text -whitespace
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
# Preserve the independently reviewed codec handoff bytes.
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
Generated
+64 -1
View File
@@ -65,6 +65,15 @@ dependencies = [
"libc",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2107,6 +2116,25 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2153,9 +2181,13 @@ name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
"stacker",
"tempfile",
"thiserror 2.0.20",
"uuid",
@@ -2176,7 +2208,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
@@ -2487,6 +2518,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2710,6 +2750,16 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3430,6 +3480,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
+5 -1
View File
@@ -7,11 +7,15 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["unbounded_depth"] }
stacker = "0.1.25"
uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
num-bigint = "0.5.1"
num-integer = "0.1.47"
num-traits = "0.2.19"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+58
View File
@@ -0,0 +1,58 @@
//! Schema 8 engineering definitions and static admission. No expression evaluation.
mod admission;
mod expression_lifecycle;
pub(crate) mod expression_serde;
pub mod references;
mod resolve;
mod typecheck;
mod types;
pub(crate) use admission::admit_structure_checked;
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
pub use types::*;
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
pub const MAX_EXPRESSION_DEPTH: usize = 128;
pub const MAX_CLOSURE_NODES: usize = 65_536;
pub const MAX_LIST_LENGTH: usize = 1_024;
pub const MAX_CALL_DEPTH: usize = 32;
/// Additional persisted records for existing transport ParseLimits. Definitions
/// themselves count as graph objects there; this counts parameters, overrides,
/// and every stored expression node, with saturating arithmetic and no recursion.
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
let mut count = 0usize;
fn binding(value: &Binding, count: &mut usize) {
if let Binding::Expression(expression) = value {
references::walk(expression, &mut |_| {
*count = count.saturating_add(1);
});
}
}
for definition in project.definitions.values() {
if let DefinitionBody::Calculation(calculation) = &definition.body {
count = count.saturating_add(calculation.parameters.len());
binding(&calculation.body, &mut count);
}
}
for element in project.elements.values() {
match element.shared() {
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
count = count.saturating_add(overrides.len());
for value in overrides.values() {
binding(value, &mut count);
}
}
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
binding(predicate, &mut count)
}
_ => {}
}
}
count
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
//! Ordinary expression ownership never consumes stack proportional to tree depth.
//! This also covers a partially decoded expression and rejected programmatic input.
use super::Expr;
use std::{fmt, mem};
impl Expr {
fn clone_shell(&self) -> Self {
let leaf = || Box::new(Self::Boolean(false));
match self {
Self::Boolean(value) => Self::Boolean(*value),
Self::Number(value) => Self::Number(value.clone()),
Self::Quantity { magnitude, unit } => Self::Quantity {
magnitude: magnitude.clone(),
unit: *unit,
},
Self::Property(id) => Self::Property(*id),
Self::Parameter(id) => Self::Parameter(*id),
Self::List(values) => {
Self::List((0..values.len()).map(|_| Self::Boolean(false)).collect())
}
Self::Unary { op, .. } => Self::Unary {
op: *op,
operand: leaf(),
},
Self::Binary { op, .. } => Self::Binary {
op: *op,
left: leaf(),
right: leaf(),
},
Self::If { .. } => Self::If {
condition: leaf(),
when_true: leaf(),
when_false: leaf(),
},
Self::Call {
definition,
arguments,
} => Self::Call {
definition: *definition,
arguments: arguments
.keys()
.map(|id| (*id, Self::Boolean(false)))
.collect(),
},
Self::Sum { .. } => Self::Sum {
values: leaf(),
initial: leaf(),
},
Self::Power { exponent, .. } => Self::Power {
base: leaf(),
exponent: *exponent,
},
Self::AsKind { kind, .. } => Self::AsKind {
kind: *kind,
value: leaf(),
},
}
}
fn detach_children(&mut self, pending: &mut Vec<Self>) {
let mut take = |child: &mut Box<Self>| {
pending.push(mem::replace(child.as_mut(), Self::Boolean(false)));
};
match self {
Self::Unary { operand, .. } => take(operand),
Self::Binary { left, right, .. } => {
take(left);
take(right);
}
Self::If {
condition,
when_true,
when_false,
} => {
take(condition);
take(when_true);
take(when_false);
}
Self::Sum { values, initial } => {
take(values);
take(initial);
}
Self::Power { base, .. } => take(base),
Self::AsKind { value, .. } => take(value),
Self::List(values) => pending.append(values),
Self::Call { arguments, .. } => {
pending.extend(mem::take(arguments).into_values());
}
Self::Boolean(_)
| Self::Number(_)
| Self::Quantity { .. }
| Self::Property(_)
| Self::Parameter(_) => {}
}
}
}
impl Drop for Expr {
fn drop(&mut self) {
let mut pending = Vec::new();
self.detach_children(&mut pending);
while let Some(mut node) = pending.pop() {
node.detach_children(&mut pending);
// node now owns only leaves/empty collections. Its ordinary drop
// reenters this method at most once, independently of original depth.
}
}
}
impl Clone for Expr {
fn clone(&self) -> Self {
let mut result = Self::Boolean(false);
let mut pending = vec![(self, &mut result)];
while let Some((source, destination)) = pending.pop() {
*destination = source.clone_shell();
match (source, destination) {
(Self::List(a), Self::List(b)) => pending.extend(a.iter().zip(b.iter_mut())),
(Self::Unary { operand: a, .. }, Self::Unary { operand: b, .. })
| (Self::Power { base: a, .. }, Self::Power { base: b, .. })
| (Self::AsKind { value: a, .. }, Self::AsKind { value: b, .. }) => {
pending.push((a, b));
}
(
Self::Binary {
left: a, right: b, ..
},
Self::Binary {
left: c, right: d, ..
},
)
| (
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(Self::Call { arguments: a, .. }, Self::Call { arguments: b, .. }) => {
pending.extend(a.values().zip(b.values_mut()));
}
_ => {}
}
}
result
}
}
impl PartialEq for Expr {
fn eq(&self, other: &Self) -> bool {
let mut pending = vec![(self, other)];
while let Some((a, b)) = pending.pop() {
match (a, b) {
(Self::Boolean(a), Self::Boolean(b)) if a == b => {}
(Self::Number(a), Self::Number(b)) if a == b => {}
(
Self::Quantity {
magnitude: a,
unit: b,
},
Self::Quantity {
magnitude: c,
unit: d,
},
) if a == c && b == d => {}
(Self::Property(a), Self::Property(b)) if a == b => {}
(Self::Parameter(a), Self::Parameter(b)) if a == b => {}
(Self::List(a), Self::List(b)) if a.len() == b.len() => {
pending.extend(a.iter().zip(b.iter()));
}
(Self::Unary { op: a, operand: b }, Self::Unary { op: c, operand: d })
if a == c =>
{
pending.push((b, d));
}
(
Self::Binary {
op: a,
left: b,
right: c,
},
Self::Binary {
op: d,
left: e,
right: f,
},
) if a == d => {
pending.push((b, e));
pending.push((c, f));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(
Self::Call {
definition: a,
arguments: b,
},
Self::Call {
definition: c,
arguments: d,
},
) if a == c && b.len() == d.len() => {
for ((key_a, value_a), (key_b, value_b)) in b.iter().zip(d.iter()) {
if key_a != key_b {
return false;
}
pending.push((value_a, value_b));
}
}
(
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::Power {
base: a,
exponent: b,
},
Self::Power {
base: c,
exponent: d,
},
) if b == d => {
pending.push((a, c));
}
(Self::AsKind { kind: a, value: b }, Self::AsKind { kind: c, value: d })
if a == c =>
{
pending.push((b, d));
}
_ => return false,
}
}
true
}
}
impl Eq for Expr {}
impl fmt::Debug for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
enum Task<'a> {
Node(&'a Expr),
Text(&'static str),
Scalar(&'a dyn fmt::Debug),
}
use Task::*;
let mut pending = vec![Node(self)];
while let Some(task) = pending.pop() {
match task {
Text(text) => f.write_str(text)?,
Scalar(value) => write!(f, "{value:?}")?,
Node(node) => match node {
Self::Boolean(value) => write!(f, "Boolean({value:?})")?,
Self::Number(value) => write!(f, "Number({value:?})")?,
Self::Quantity { magnitude, unit } => {
write!(f, "Quantity {{ magnitude: {magnitude:?}, unit: {unit:?} }}")?;
}
Self::Property(id) => write!(f, "Property({id:?})")?,
Self::Parameter(id) => write!(f, "Parameter({id:?})")?,
Self::List(values) => {
f.write_str("List([")?;
pending.push(Text("])"));
for (index, value) in values.iter().enumerate().rev() {
pending.push(Node(value));
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Unary { op, operand } => {
write!(f, "Unary {{ op: {op:?}, operand: ")?;
pending.extend([Text(" }"), Node(operand)]);
}
Self::Binary { op, left, right } => {
write!(f, "Binary {{ op: {op:?}, left: ")?;
pending.extend([Text(" }"), Node(right), Text(", right: "), Node(left)]);
}
Self::If {
condition,
when_true,
when_false,
} => {
f.write_str("If { condition: ")?;
pending.extend([
Text(" }"),
Node(when_false),
Text(", when_false: "),
Node(when_true),
Text(", when_true: "),
Node(condition),
]);
}
Self::Call {
definition,
arguments,
} => {
write!(f, "Call {{ definition: {definition:?}, arguments: {{")?;
pending.push(Text("} }"));
for (index, (id, value)) in arguments.iter().enumerate().rev() {
pending.extend([Node(value), Text(": "), Scalar(id)]);
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Sum { values, initial } => {
f.write_str("Sum { values: ")?;
pending.extend([
Text(" }"),
Node(initial),
Text(", initial: "),
Node(values),
]);
}
Self::Power { base, exponent } => {
f.write_str("Power { base: ")?;
pending.extend([
Text(" }"),
Scalar(exponent),
Text(", exponent: "),
Node(base),
]);
}
Self::AsKind { kind, value } => {
write!(f, "AsKind {{ kind: {kind:?}, value: ")?;
pending.extend([Text(" }"), Node(value)]);
}
},
}
}
Ok(())
}
}
@@ -0,0 +1,261 @@
//! The existing externally tagged Serde schema, with admission at each Expr edge.
//! The private remote derive is the sole wire grammar: it still rejects unknown
//! fields/variants, duplicate fields and malformed UUIDs/Exact values.
use super::{
BinaryOp, DefinitionRef, Expr, MAX_CLOSURE_NODES, MAX_EXPRESSION_DEPTH, MAX_LIST_LENGTH,
ParameterId, UnaryOp,
};
use crate::{Id, analysis_numeric::Exact};
use serde::{
Deserialize, Serialize,
de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
};
use std::{cell::Cell, collections::BTreeMap, fmt};
pub(crate) const MAX_JSON_EXPRESSION_NODES: usize = 262_144;
// These are requested segment capacities, not an allocator or RSS guarantee.
const RED_ZONE: usize = 64 * 1024;
const STACK_SEGMENT: usize = 512 * 1024;
pub(crate) fn with_stack<R>(operation: impl FnOnce() -> R) -> R {
stacker::maybe_grow(RED_ZONE, STACK_SEGMENT, operation)
}
#[derive(Clone, Copy)]
struct Meter {
active: bool,
depth: usize,
nodes: usize,
tree_nodes: usize,
maximum: usize,
}
impl Meter {
const DEFAULT: Self = Self {
active: false,
depth: 0,
nodes: 0,
tree_nodes: 0,
maximum: MAX_JSON_EXPRESSION_NODES,
};
}
thread_local! {
static DECODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
static ENCODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
}
type LocalMeter = &'static std::thread::LocalKey<Cell<Meter>>;
/// One codec operation owns the aggregate meter across all disjoint bindings.
/// Scope restoration also runs on errors and unwinding; no mutable TLS borrow is
/// held across Serde/user code or a stack switch.
pub(crate) struct Scope {
local: LocalMeter,
previous: Meter,
}
impl Scope {
pub(crate) fn decoding(maximum: usize) -> Self {
Self::new(&DECODING, maximum)
}
pub(crate) fn encoding(maximum: usize) -> Self {
Self::new(&ENCODING, maximum)
}
fn new(local: LocalMeter, maximum: usize) -> Self {
let next = Meter {
active: true,
maximum: maximum.min(MAX_JSON_EXPRESSION_NODES),
..Meter::DEFAULT
};
let previous = local.with(|meter| meter.replace(next));
Self { local, previous }
}
}
impl Drop for Scope {
fn drop(&mut self) {
self.local.with(|meter| meter.set(self.previous));
}
}
struct NodeGuard {
local: LocalMeter,
root: bool,
}
impl NodeGuard {
fn enter(local: LocalMeter) -> Result<Self, &'static str> {
local.with(|slot| {
let mut meter = slot.get();
let root = !meter.active;
if root {
meter = Meter {
active: true,
..Meter::DEFAULT
};
}
if meter.depth == 0 {
meter.tree_nodes = 0;
}
if meter.depth >= MAX_EXPRESSION_DEPTH {
return Err("Expression logical depth exceeds 128.");
}
if meter.nodes >= meter.maximum {
return Err("JSON expression allocation count exceeded.");
}
if meter.tree_nodes >= MAX_CLOSURE_NODES {
return Err("Stored expression node count exceeds 65536.");
}
meter.depth += 1;
meter.nodes += 1;
meter.tree_nodes += 1;
slot.set(meter);
Ok(Self { local, root })
})
}
}
impl Drop for NodeGuard {
fn drop(&mut self) {
self.local.with(|slot| {
let mut meter = slot.get();
meter.depth -= 1;
if self.root {
meter.active = false;
}
slot.set(meter);
});
}
}
impl<'de> Deserialize<'de> for Expr {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let _node = NodeGuard::enter(&DECODING).map_err(serde::de::Error::custom)?;
with_stack(|| WireExpr::deserialize(deserializer))
}
}
impl Serialize for Expr {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let _node = NodeGuard::enter(&ENCODING).map_err(serde::ser::Error::custom)?;
match self {
Self::List(values) if values.len() > MAX_LIST_LENGTH => {
return Err(serde::ser::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Self::Call { arguments, .. } if arguments.len() > MAX_CLOSURE_NODES => {
return Err(serde::ser::Error::custom(
"Call argument count exceeds 65536.",
));
}
_ => {}
}
with_stack(|| WireExpr::serialize(self, serializer))
}
}
// A private remote derive avoids exposing an unguarded inherent deserialize on
// public Expr. Children remain Expr, so every recursive edge enters the guard.
#[derive(Serialize, Deserialize)]
#[serde(remote = "Expr", deny_unknown_fields)]
enum WireExpr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(#[serde(deserialize_with = "bounded_list")] Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
#[serde(deserialize_with = "bounded_arguments")]
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
struct ListItem {
allowed: bool,
}
impl<'de> DeserializeSeed<'de> for ListItem {
type Value = Expr;
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<Expr, D::Error> {
if !self.allowed {
return Err(serde::de::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Expr::deserialize(deserializer)
}
}
fn bounded_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Expr>, D::Error> {
struct ListVisitor;
impl<'de> Visitor<'de> for ListVisitor {
type Value = Vec<Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("at most 1024 expression items")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = access.next_element_seed(ListItem {
allowed: values.len() < MAX_LIST_LENGTH,
})? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_seq(ListVisitor)
}
fn bounded_arguments<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
struct Arguments;
impl<'de> Visitor<'de> for Arguments {
type Value = BTreeMap<ParameterId, Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("unique scoped parameter UUID keys")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = BTreeMap::new();
while let Some(id) = access.next_key::<ParameterId>()? {
if values.contains_key(&id) {
return Err(serde::de::Error::custom(format!(
"Duplicate parameter ID {}",
id.0
)));
}
if values.len() >= MAX_CLOSURE_NODES {
return Err(serde::de::Error::custom(
"Call argument count exceeds 65536.",
));
}
// Count/duplicate checks precede construction of the next subtree.
values.insert(id, access.next_value()?);
}
Ok(values)
}
}
deserializer.deserialize_map(Arguments)
}
+246
View File
@@ -0,0 +1,246 @@
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
use super::*;
use crate::{Element, Id, Project, SharedBody};
use std::collections::BTreeSet;
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| {
if let Expr::Property(id) = node {
found.insert(*id);
}
});
found
}
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| match node {
Expr::Quantity { unit, .. } => {
found.insert(unit.local());
}
Expr::Call { definition, .. } => {
found.insert(definition.local());
}
Expr::AsKind { kind, .. } => {
found.insert(kind.local());
}
_ => {}
});
found
}
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter().rev()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(right);
pending.push(left);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(when_false);
pending.push(when_true);
pending.push(condition);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter_mut()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(left);
pending.push(right);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(condition);
pending.push(when_true);
pending.push(when_false);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
let mut result = BTreeSet::new();
let mut binding = |value: &Binding| {
if let Binding::Expression(expression) = value {
result.extend(expression_properties(expression));
}
};
match element.shared() {
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
Some(SharedBody::AnalysisCase { overrides, .. }) => {
for value in overrides.values() {
binding(value);
}
}
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
_ => {}
}
match element.shared() {
Some(SharedBody::EngineeringProperty { subject, .. }) => {
result.insert(subject.id());
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(overrides.keys());
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(documented_constraint);
}
_ => {}
}
result
}
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
let DefinitionRef::Local(id) = reference;
if *id == old {
*id = new;
}
}
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
if let ScalarType::Quantity(reference) = scalar {
remap_ref(reference, old, new);
}
}
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
if let Binding::Expression(expression) = value {
walk_mut(expression, &mut |node| match node {
Expr::Property(id) if *id == old => *id = new,
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
Expr::Call { definition, .. } => remap_ref(definition, old, new),
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
_ => {} // Parameter nodes and argument keys are scoped, never global.
});
}
}
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
if *id == old {
*id = new;
}
}
/// Check before any mutation so a map-key collision never silently discards a binding.
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
if old != new {
for element in project.elements.values() {
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
&& overrides.contains_key(&old)
&& overrides.contains_key(&new)
{
return Err(AnalysisError::new(
element.id,
"overrides",
"analysis_reference_collision",
"Reidentification would merge two distinct override keys.",
));
}
}
}
Ok(())
}
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
for element in project.elements.values_mut() {
match element.shared_mut() {
Some(SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
}) => {
remap_subject(subject, old, new);
remap_type(value_type, old, new);
if let Some(reference) = display_unit {
remap_ref(reference, old, new);
}
remap_binding(base, old, new);
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if old != new
&& let Some(value) = overrides.remove(&old)
{
overrides.insert(new, value);
}
for value in overrides.values_mut() {
remap_binding(value, old, new);
}
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if *documented_constraint == Some(old) {
*documented_constraint = Some(new);
}
remap_binding(predicate, old, new);
}
_ => {}
}
}
for definition in project.definitions.values_mut() {
match &mut definition.body {
DefinitionBody::QuantityKind(QuantityKindDefinition {
algebra: QuantityAlgebra::Affine { difference },
..
}) => remap_ref(difference, old, new),
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
DefinitionBody::Calculation(calculation) => {
for parameter in &mut calculation.parameters {
remap_type(&mut parameter.value_type, old, new);
}
remap_type(&mut calculation.result, old, new);
remap_binding(&mut calculation.body, old, new);
}
_ => {}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
use crate::{Id, Project, SharedBody};
use std::collections::BTreeMap;
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KindHandle {
model: Id,
definition: Id,
}
impl KindHandle {
pub fn definition(self) -> Id {
self.definition
}
pub(crate) fn local(model: Id, definition: Id) -> Self {
Self { model, definition }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedScalar {
Boolean,
Numeric(NumericType<KindHandle>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedType {
Scalar(ResolvedScalar),
List(ResolvedScalar),
}
impl ResolvedType {
pub(crate) fn boolean() -> Self {
Self::Scalar(ResolvedScalar::Boolean)
}
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveBinding<'a> {
pub binding: &'a Binding,
pub origin: BindingOrigin,
}
/// Constructed only by admission; exact source/capture provenance stays with its owner.
/// Values from different accepted environments must not be mixed by an evaluator.
pub struct ResolvedAnalysis<'p> {
pub(super) project: &'p Project,
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
pub(super) limits: AdmissionLimits,
pub(super) usage: AdmissionUsage,
}
impl<'p> ResolvedAnalysis<'p> {
pub fn project(&self) -> &'p Project {
self.project
}
pub fn admission_usage(&self) -> AdmissionUsage {
self.usage
}
pub fn admission_limits(&self) -> AdmissionLimits {
self.limits
}
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
self.project.definitions.get(&reference.local())
}
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
self.kinds.get(&reference.local())
}
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
self.units.get(&reference.local())
}
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
match &self.definition(reference)?.body {
DefinitionBody::Calculation(value) => Some(value),
_ => None,
}
}
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
let scalar = match scalar {
ScalarType::Boolean => ResolvedScalar::Boolean,
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
ScalarType::Quantity(reference) => {
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
}
};
Some(match value {
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
AnalysisType::List(_) => ResolvedType::List(scalar),
})
}
pub fn effective_binding(
&self,
property: Id,
context: EvaluationContext,
) -> Result<EffectiveBinding<'p>, AnalysisError> {
let element = self.project.elements.get(&property).ok_or_else(|| {
AnalysisError::new(
property,
"context",
"analysis_property",
"Property does not exist.",
)
})?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
return Err(AnalysisError::new(
property,
"context",
"analysis_property",
"Target is not an engineering property.",
));
};
if let EvaluationContext::Case(id) = context {
let case = self.project.elements.get(&id).ok_or_else(|| {
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
})?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
return Err(AnalysisError::new(
id,
"context",
"analysis_case",
"Context is not an analysis case.",
));
};
if case.layer() != element.layer() {
return Err(AnalysisError::new(
id,
"context",
"analysis_layer",
"Case and property must share a layer.",
));
}
if let Some(binding) = overrides.get(&property) {
return Ok(EffectiveBinding {
binding,
origin: BindingOrigin::CaseOverride(id),
});
}
}
Ok(EffectiveBinding {
binding: base,
origin: BindingOrigin::ModelBaseline,
})
}
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
//! Static expression checking shares numeric operator algebra with execution.
use super::admission::Budget;
use super::*;
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
use crate::{Id, Layer, SharedBody};
use std::collections::BTreeMap;
pub(super) struct BindingContext<'a> {
pub owner: Id,
pub field: &'a str,
pub layer: Option<Layer>,
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
}
pub(super) fn binding(
environment: &ResolvedAnalysis<'_>,
context: BindingContext<'_>,
value: &Binding,
expected: &ResolvedType,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
if let Binding::Expression(expression) = value {
let mut checker = Checker {
environment,
owner: context.owner,
field: context.field,
layer: context.layer,
parameters: context.parameters,
budget,
};
checker.expression(expression, Some(expected))?;
}
Ok(())
}
struct Checker<'a, 'p, 'b> {
environment: &'a ResolvedAnalysis<'p>,
owner: Id,
field: &'a str,
layer: Option<Layer>,
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
budget: &'b mut Budget,
}
/// One suspended expression per logical depth. No Expr is owned or cloned by
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
struct Frame<'e, 'p> {
expression: &'e Expr,
expected: Option<ResolvedType>,
state: State<'p>,
}
enum State<'p> {
Enter,
Unary,
BinaryLeft,
BinaryRight(ResolvedType),
IfCondition,
IfTrue,
IfFalse(ResolvedType),
List {
next: usize,
},
Call {
calculation: &'p CalculationDefinition,
next: usize,
},
SumInitial,
SumValues(ResolvedType),
Power,
AsKind(ResolvedKind<KindHandle>),
}
enum Step<'e> {
Child(&'e Expr, Option<ResolvedType>),
Complete(ResolvedType),
}
impl<'p> Checker<'_, 'p, '_> {
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
AnalysisError::new(self.owner, self.field, code, message)
}
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
match value {
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
}
}
fn operation(
&self,
result: Result<NumericType<KindHandle>, NumericError>,
) -> Result<ResolvedType, AnalysisError> {
result
.map(ResolvedType::numeric)
.map_err(|error| self.error("analysis_type", error.to_string()))
}
/// Explicit continuations replace Rust recursion. Width never expands the
/// frame stack: lists and calls schedule only their next ordered child.
/// At most 128 frames and one returned scalar/list type are retained. The
/// per-node/per-argument meter charges remain identical to recursive entry;
/// continuation steps do constant bounded work within those charges.
fn expression(
&mut self,
expression: &Expr,
expected: Option<&ResolvedType>,
) -> Result<ResolvedType, AnalysisError> {
let mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
frames.push(Frame {
expression,
expected: expected.cloned(),
state: State::Enter,
});
let mut returned = None;
loop {
let frame = frames.last_mut().expect("active expression frame");
match self.advance(frame, returned.take())? {
Step::Child(expression, expected) => {
if frames.len() == MAX_EXPRESSION_DEPTH {
// Preserve entry charging/error precedence without ever
// allocating the rejected 129th frame.
self.budget.charge(self.owner, self.field, 1)?;
return Err(self.error(
"analysis_depth",
"Expression exceeds the logical depth limit.",
));
}
frames.push(Frame {
expression,
expected,
state: State::Enter,
});
}
Step::Complete(actual) => {
let frame = frames.pop().expect("completed expression frame");
let expected = frame.expected.as_ref();
if expected.is_some_and(|expected| expected != &actual) {
return Err(self.error(
"analysis_type",
format!("Expected {expected:?}; expression has {actual:?}."),
));
}
if frames.is_empty() {
return Ok(actual);
}
returned = Some(actual);
}
}
}
}
fn advance<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
returned: Option<ResolvedType>,
) -> Result<Step<'e>, AnalysisError> {
let state = std::mem::replace(&mut frame.state, State::Enter);
if let State::Enter = state {
self.budget.charge(self.owner, self.field, 1)?;
return self.enter(frame);
}
let child = returned.expect("continuation follows a completed child");
match state {
State::Enter => unreachable!("handled before consuming child"),
State::Unary => {
let Expr::Unary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(match op {
UnaryOp::Not => ResolvedType::boolean(),
UnaryOp::Negate | UnaryOp::Absolute => {
self.operation(self.numeric(child)?.unary_result())?
}
}))
}
State::BinaryLeft => {
let Expr::Binary { right, .. } = frame.expression else {
unreachable!()
};
frame.state = State::BinaryRight(child);
Ok(Step::Child(right, None))
}
State::BinaryRight(left) => {
let Expr::Binary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(self.binary(*op, left, child)?))
}
State::IfCondition => {
let Expr::If { when_true, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfTrue;
Ok(Step::Child(when_true, frame.expected.clone()))
}
State::IfTrue => {
let Expr::If { when_false, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfFalse(child.clone());
Ok(Step::Child(when_false, Some(child)))
}
State::IfFalse(value) => Ok(Step::Complete(value)),
State::List { next } => {
let ResolvedType::Scalar(scalar) = child else {
return Err(self.error(
"analysis_list",
"Lists are flat; nested lists are not admitted.",
));
};
let Expr::List(items) = frame.expression else {
unreachable!()
};
if let Some(item) = items.get(next) {
frame.state = State::List { next: next + 1 };
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
} else {
Ok(Step::Complete(ResolvedType::List(scalar)))
}
}
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
State::SumInitial => {
let numeric = self.numeric(child.clone())?;
self.operation(numeric.unary_result())?;
let ResolvedType::Scalar(scalar) = &child else {
unreachable!()
};
let expected = ResolvedType::List(scalar.clone());
let Expr::Sum { values, .. } = frame.expression else {
unreachable!()
};
frame.state = State::SumValues(child);
Ok(Step::Child(values, Some(expected)))
}
State::SumValues(initial) => {
let numeric = self.numeric(initial.clone())?;
self.operation(numeric.additive_result(&numeric, false))?;
Ok(Step::Complete(initial))
}
State::Power => {
let Expr::Power { exponent, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(
self.operation(self.numeric(child)?.power_result(*exponent))?,
))
}
State::AsKind(kind) => Ok(Step::Complete(
self.operation(self.numeric(child)?.assertion_result(&kind))?,
)),
}
}
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
let actual = match frame.expression {
Expr::Boolean(_) => ResolvedType::boolean(),
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
Expr::Quantity { unit, .. } => {
let unit = self.environment.unit(*unit).ok_or_else(|| {
self.error(
"analysis_unit",
"Quantity literal must reference an existing unit definition.",
)
})?;
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
}
Expr::Property(id) => {
let layer = self.layer.ok_or_else(|| {
self.error(
"analysis_purity",
"Reusable calculations cannot capture project properties.",
)
})?;
let element =
self.environment.project.elements.get(id).ok_or_else(|| {
self.error("analysis_property", "Property does not exist.")
})?;
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
else {
return Err(self.error(
"analysis_property",
"Reference must target an engineering property.",
));
};
if element.layer() != layer {
return Err(self.error(
"analysis_layer",
"Property references must stay in the same layer.",
));
}
self.environment.declared_type(value_type).ok_or_else(|| {
self.error("analysis_kind", "Declared property kind does not resolve.")
})?
}
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
self.error(
"analysis_parameter",
"Parameter does not belong to this calculation definition.",
)
})?,
Expr::List(items) => {
if items.len() > MAX_LIST_LENGTH {
return Err(
self.error("analysis_list", "List exceeds the flat-list length limit.")
);
}
let scalar = match &frame.expected {
Some(ResolvedType::List(value)) => Some(value.clone()),
_ => None,
};
if let Some(item) = items.first() {
frame.state = State::List { next: 1 };
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
}
ResolvedType::List(scalar.ok_or_else(|| {
self.error(
"analysis_list",
"An empty list requires an explicit surrounding list type.",
)
})?)
}
Expr::Unary { op, operand } => {
frame.state = State::Unary;
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
return Ok(Step::Child(operand, expected));
}
Expr::Binary { left, .. } => {
frame.state = State::BinaryLeft;
return Ok(Step::Child(left, None));
}
Expr::If { condition, .. } => {
frame.state = State::IfCondition;
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
}
Expr::Call {
definition,
arguments,
} => {
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
self.error(
"analysis_calculation",
"Call must reference a calculation definition.",
)
})?;
if arguments.len() != calculation.parameters.len() {
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
// Charge the complete key scan before any parameter lookup.
self.budget
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
if calculation
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.id))
{
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
return self.call_argument(frame, calculation, 0);
}
Expr::Sum { initial, .. } => {
frame.state = State::SumInitial;
return Ok(Step::Child(initial, None));
}
Expr::Power { base, .. } => {
frame.state = State::Power;
return Ok(Step::Child(base, None));
}
Expr::AsKind { kind, value } => {
let kind = self
.environment
.kind(*kind)
.ok_or_else(|| {
self.error(
"analysis_kind",
"Kind assertion must reference a quantity kind.",
)
})?
.clone();
frame.state = State::AsKind(kind);
return Ok(Step::Child(value, None));
}
};
Ok(Step::Complete(actual))
}
/// Arguments follow parameter declaration order, not UUID map order. Retain
/// the resolved calculation by reference and schedule one argument at a time.
fn call_argument<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
calculation: &'p CalculationDefinition,
next: usize,
) -> Result<Step<'e>, AnalysisError> {
if let Some(parameter) = calculation.parameters.get(next) {
self.budget.charge(self.owner, self.field, 1)?;
let expected = self
.environment
.declared_type(&parameter.value_type)
.ok_or_else(|| self.error("analysis_kind", "Parameter kind does not resolve."))?;
let Expr::Call { arguments, .. } = frame.expression else {
unreachable!()
};
frame.state = State::Call {
calculation,
next: next + 1,
};
Ok(Step::Child(&arguments[&parameter.id], Some(expected)))
} else {
let result = self
.environment
.declared_type(&calculation.result)
.ok_or_else(|| {
self.error("analysis_kind", "Calculation result kind does not resolve.")
})?;
Ok(Step::Complete(result))
}
}
fn binary(
&self,
op: BinaryOp,
left: ResolvedType,
right: ResolvedType,
) -> Result<ResolvedType, AnalysisError> {
match op {
BinaryOp::And | BinaryOp::Or => {
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
return Err(self.error("analysis_type", "Boolean operands are required."));
}
Ok(ResolvedType::boolean())
}
BinaryOp::Equal | BinaryOp::NotEqual
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
{
Ok(ResolvedType::boolean())
}
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual => {
self.numeric(left)?
.comparison_compatible(&self.numeric(right)?)
.map_err(|error| self.error("analysis_type", error.to_string()))?;
Ok(ResolvedType::boolean())
}
BinaryOp::Add | BinaryOp::Subtract => self.operation(
self.numeric(left)?
.additive_result(&self.numeric(right)?, op == BinaryOp::Subtract),
),
BinaryOp::Multiply | BinaryOp::Divide => self.operation(
self.numeric(left)?
.multiplicative_result(&self.numeric(right)?, op == BinaryOp::Divide),
),
}
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
use crate::Id;
use crate::analysis_numeric::{Dimension, Exact};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Only real local definitions are admitted in schema 8's first environment.
/// Imported references require the separately implemented pinned package catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionRef {
Local(Id),
}
impl DefinitionRef {
pub fn local(self) -> Id {
match self {
Self::Local(id) => id,
}
}
}
/// Qualified by its owning CalculationDefinition; never a global model identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ParameterId(pub Id);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub body: DefinitionBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionBody {
QuantityKind(QuantityKindDefinition),
Unit(UnitDefinition),
Calculation(CalculationDefinition),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuantityKindDefinition {
pub dimension: Dimension,
pub algebra: QuantityAlgebra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum QuantityAlgebra {
Linear,
Affine { difference: DefinitionRef },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnitDefinition {
pub kind: DefinitionRef,
pub scale: Exact,
pub offset: Exact,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ScalarType {
Boolean,
Number,
Quantity(DefinitionRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisType {
Scalar(ScalarType),
List(ScalarType),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Parameter {
pub id: ParameterId,
pub name: String,
pub value_type: AnalysisType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalculationDefinition {
pub parameters: Vec<Parameter>,
pub result: AnalysisType,
pub body: Binding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Binding {
Unbound,
Expression(Expr),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnaryOp {
Negate,
Absolute,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
Equal,
NotEqual,
Less,
LessOrEqual,
Greater,
GreaterOrEqual,
And,
Or,
}
pub enum Expr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisSubject {
Element(Id),
Relation(Id),
}
impl AnalysisSubject {
pub fn id(self) -> Id {
match self {
Self::Element(id) | Self::Relation(id) => id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaselineBase {
ModelBaseline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EvaluationContext {
ModelBaseline,
Case(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum BindingOrigin {
ModelBaseline,
CaseOverride(Id),
}
/// Located structural/static admission failure, not a false or failed evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(deny_unknown_fields)]
#[error("{code} at {object}.{field}: {message}")]
pub struct AnalysisError {
pub object: Id,
pub field: String,
pub code: String,
pub message: String,
}
impl AnalysisError {
pub(crate) fn new(
object: Id,
field: impl Into<String>,
code: &str,
message: impl Into<String>,
) -> Self {
Self {
object,
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
//!
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
//! expression/call/property/list/target visits and retain the same logical walk on
//! cache hits. Stored AST size is not a dynamic execution limit.
//!
//! Approximate engineering functions/irrational unit scales require a subsequent
//! explicit numeric profile; rationals do not implement degree/radian conversion
//! or transcendental mathematics.
mod dimension;
mod exact;
mod meter;
mod quantity;
pub use dimension::{Dimension, DimensionAxis};
pub use exact::{Exact, ExactWire};
pub use meter::{
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
WorkSite, WorkUsage,
};
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Profile identity includes the accounting rules, not crate-internal timing.
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
pub const MAX_COMPONENT_BITS: u64 = 4096;
/// Cross products require 8192 bits; their sum can require one additional bit.
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
pub const MAX_COMPONENT_DIGITS: usize = 1234;
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
pub const MAX_INTEGER_POWER: i8 = 16;
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumberComponent {
Numerator,
Denominator,
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericLimit {
#[error("request work exhausted: {0}")]
Work(WorkExhaustion),
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
Bits {
component: NumberComponent,
bits: u64,
limit: u64,
stage: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericError {
#[error("numeric limit: {0}")]
Limit(NumericLimit),
#[error("invalid numeric literal")]
InvalidLiteral,
#[error("numeric literal exceeds its digit or byte limit")]
LiteralTooLong,
#[error("decimal exponent is outside -128 through 128")]
DecimalExponent,
#[error("wire rational is not in canonical reduced form")]
NonCanonicalWire,
#[error("rational denominator is zero")]
ZeroDenominator,
#[error("division by zero")]
DivisionByZero,
#[error("zero to the power zero is undefined in this profile")]
ZeroPowerZero,
#[error("integer power is outside -16 through 16")]
IntegerPower,
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
DimensionExponent { axis: DimensionAxis, value: i32 },
#[error("unit scale must be strictly positive")]
UnitScale,
#[error("a linear unit must have zero offset")]
LinearOffset,
#[error("an affine point must have a distinct linear difference kind")]
DifferenceKind,
#[error("numeric kinds or dimensions are incompatible")]
IncompatibleTypes,
#[error("operation is not defined for affine points")]
AffineOperation,
#[error("AsKind requires an anonymous matching dimension and a linear target")]
KindAssertion,
#[error("request work limits exceed the provisional reference ceilings")]
InvalidWorkLimits,
}
impl NumericError {
pub fn is_terminal_work_exhaustion(&self) -> bool {
matches!(self, Self::Limit(NumericLimit::Work(_)))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,107 @@
use super::NumericError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionAxis {
Length,
Mass,
Time,
ElectricCurrent,
Temperature,
Amount,
LuminousIntensity,
}
impl DimensionAxis {
pub const ALL: [Self; 7] = [
Self::Length,
Self::Mass,
Self::Time,
Self::ElectricCurrent,
Self::Temperature,
Self::Amount,
Self::LuminousIntensity,
];
}
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
/// This is a dimension, never a nominal quantity-kind compatibility proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
pub struct Dimension([i16; 7]);
impl Dimension {
pub const ZERO: Self = Self([0; 7]);
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
for (index, value) in exponents.iter().copied().enumerate() {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value: i32::from(value),
});
}
}
Ok(Self(exponents))
}
pub fn exponents(self) -> [i16; 7] {
self.0
}
pub fn is_dimensionless(self) -> bool {
self == Self::ZERO
}
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, 1)
}
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, -1)
}
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
}
Ok(Self(output))
}
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] =
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
}
Ok(Self(output))
}
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value,
});
}
Ok(value as i16)
}
}
impl TryFrom<[i16; 7]> for Dimension {
type Error = NumericError;
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Dimension> for [i16; 7] {
fn from(value: Dimension) -> Self {
value.0
}
}
@@ -0,0 +1,469 @@
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
use super::{
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
NumericOperation, WorkLimits, WorkMeter,
};
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, Zero};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
/// The interchange representation deliberately does not use num-bigint's serde
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
/// Its derived decoder allocates String fields before numeric validation; a
/// transport/document adapter must bound the encoded envelope and decoded text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExactWire {
pub numerator: String,
pub denominator: String,
}
/// Reduced rational with positive denominator and <=4096-bit components.
///
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
/// of already normalized bounded values, not an expression comparison API.
/// Standalone serde admission bounds the subsequent BigInt validation, not the
/// preceding String decoding. Document adapters must bound encoded/decoded input
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ExactWire", into = "ExactWire")]
pub struct Exact {
numerator: BigInt,
denominator: BigInt,
}
impl Exact {
pub fn from_integer(value: i64) -> Self {
Self {
numerator: BigInt::from(value),
denominator: BigInt::one(),
}
}
pub fn is_zero(&self) -> bool {
self.numerator.is_zero()
}
pub fn is_positive(&self) -> bool {
self.numerator.is_positive()
}
pub fn component_bits(&self) -> (u64, u64) {
(self.numerator.bits(), self.denominator.bits())
}
pub fn to_wire(&self) -> ExactWire {
ExactWire {
numerator: self.numerator.to_string(),
denominator: self.denominator.to_string(),
}
}
/// Canonical *literal* spelling, not a generic expression formatter.
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
/// belongs to the future expression parser's UnaryNegate node instead.
pub fn canonical_literal(&self) -> String {
if self.denominator.is_one() && !self.numerator.is_negative() {
self.numerator.to_string()
} else {
format!("ratio({}, {})", self.numerator, self.denominator)
}
}
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let n_digits = wire_digits(&wire.numerator, true)?;
let d_digits = wire_digits(&wire.denominator, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
meter.numeric(
NumericOperation::ParseWire,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let numerator = parse_integer(&wire.numerator)?;
let denominator = parse_integer(&wire.denominator)?;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if !numerator.gcd(&denominator).is_one() {
return Err(NumericError::NonCanonicalWire);
}
Ok(Self {
numerator,
denominator,
})
}
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
/// It does not parse expressions, unary operators, units, calls or names.
/// Source ratios/decimals may reduce; wire values must already be canonical.
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if source.len() > MAX_LITERAL_BYTES {
return Err(NumericError::LiteralTooLong);
}
let source = trim_ascii(source);
if let Some(body) = source.strip_prefix("ratio(") {
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
}
Self::parse_decimal(source, meter)
}
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let n_digits = source_integer_digits(n, true)?;
let d_digits = source_integer_digits(d, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
}
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let mut parts = source.split(['e', 'E']);
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
let exponent = match parts.next() {
Some(text) => parse_exponent(text)?,
None => 0,
};
if parts.next().is_some() {
return Err(NumericError::InvalidLiteral);
}
let (whole, fraction) = match mantissa.split_once('.') {
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
Some(_) => return Err(NumericError::InvalidLiteral),
None => (mantissa, ""),
};
source_integer_digits(whole, false)?;
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let digit_count = whole.len() + fraction.len();
if digit_count > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
let adjustment = exponent - fraction.len() as i32;
let power = adjustment.unsigned_abs();
let mantissa_bound = decimal_bits(digit_count);
let scale_bound = decimal_bits(power as usize + 1);
let (n_bound, d_bound) = if adjustment >= 0 {
(mantissa_bound + scale_bound, 1)
} else {
(mantissa_bound, scale_bound)
};
Self::check_estimates(n_bound, d_bound)?;
// Fixed logical allowance for constructing 10^power; independent of
// num-bigint's selected multiplication/exponentiation implementation.
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(digit_count, mantissa_bound)
+ power_steps * product_cost(scale_bound, scale_bound)
+ product_cost(mantissa_bound, scale_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let digits = format!("{whole}{fraction}");
let mut numerator = parse_integer(&digits)?;
let scale = BigInt::from(10_u8).pow(power);
let denominator = if adjustment >= 0 {
numerator *= scale;
BigInt::one()
} else {
scale
};
Self::normalize_precharged(numerator, denominator)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, false, meter)
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, true, meter)
}
fn add_or_subtract(
&self,
rhs: &Self,
subtract: bool,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
let left_bound = a + d;
let right_bound = c + b;
let n_bound = left_bound.max(right_bound) + 1;
let d_bound = b + d;
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
if subtract {
NumericOperation::Subtract
} else {
NumericOperation::Add
},
product_cost(a, d)
+ product_cost(c, b)
+ sum_cost(left_bound, right_bound)
+ product_cost(b, d)
+ normalization_cost(n_bound, d_bound),
)?;
let left = &self.numerator * &rhs.denominator;
let right = &rhs.numerator * &self.denominator;
let numerator = if subtract { left - right } else { left + right };
let denominator = &self.denominator * &rhs.denominator;
Self::normalize_precharged(numerator, denominator)
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + c, b + d)?;
meter.numeric(
NumericOperation::Multiply,
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
)?;
Self::normalize_precharged(
&self.numerator * &rhs.numerator,
&self.denominator * &rhs.denominator,
)
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + d, b + c)?;
meter.numeric(
NumericOperation::Divide,
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
)?;
if rhs.is_zero() {
return Err(NumericError::DivisionByZero);
}
Self::normalize_precharged(
&self.numerator * &rhs.denominator,
&self.denominator * &rhs.numerator,
)
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates((a + d).max(c + b), 1)?;
meter.numeric(
NumericOperation::Compare,
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
)?;
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
Ok(Self {
numerator: -&self.numerator,
denominator: self.denominator.clone(),
})
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
Ok(Self {
numerator: self.numerator.abs(),
denominator: self.denominator.clone(),
})
}
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
if self.is_zero() {
return Err(NumericError::DivisionByZero);
}
Ok(Self {
numerator: if self.numerator.is_negative() {
-&self.denominator
} else {
self.denominator.clone()
},
denominator: self.numerator.abs(),
})
}
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
/// Each reduced stage obeys the same component bound; no large power is
/// allocated and subsequently discarded after discovering it is oversized.
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Power, self.copy_cost())?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_zero() && exponent == 0 {
return Err(NumericError::ZeroPowerZero);
}
let factor = if exponent < 0 {
self.reciprocal(meter)?
} else {
self.clone()
};
let mut value = Self::from_integer(1);
for _ in 0..exponent.unsigned_abs() {
value = value.multiply(&factor, meter)?;
}
Ok(value)
}
pub(super) fn copy_cost(&self) -> u64 {
let (n, d) = self.component_bits();
1 + words(n) + words(d)
}
fn normalize_precharged(
mut numerator: BigInt,
mut denominator: BigInt,
) -> Result<Self, NumericError> {
Self::check_bits(
&numerator,
&denominator,
MAX_INTERMEDIATE_BITS,
"intermediate",
)?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if denominator.is_negative() {
numerator = -numerator;
denominator = -denominator;
}
let gcd = numerator.gcd(&denominator);
numerator /= &gcd;
denominator /= gcd;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
Ok(Self {
numerator,
denominator,
})
}
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
Self::check_bit_count(
n,
NumberComponent::Numerator,
MAX_INTERMEDIATE_BITS,
"predicted",
)?;
Self::check_bit_count(
d,
NumberComponent::Denominator,
MAX_INTERMEDIATE_BITS,
"predicted",
)
}
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
}
fn check_bit_count(
bits: u64,
component: NumberComponent,
limit: u64,
stage: &str,
) -> Result<(), NumericError> {
if bits > limit {
return Err(NumericError::Limit(NumericLimit::Bits {
component,
bits,
limit,
stage: stage.to_owned(),
}));
}
Ok(())
}
}
impl TryFrom<ExactWire> for Exact {
type Error = NumericError;
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
// BigInt validation is bounded after ExactWire strings have decoded.
// The adapter owns envelope/text/aggregate admission bounds and must not
// reset evaluation meters.
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
}
}
impl From<Exact> for ExactWire {
fn from(value: Exact) -> Self {
value.to_wire()
}
}
fn trim_ascii(text: &str) -> &str {
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
}
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = if signed {
text.strip_prefix('-').unwrap_or(text)
} else {
text
};
if digits.len() > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
Ok(digits)
}
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = source_integer_digits(text, signed).map_err(|error| match error {
NumericError::LiteralTooLong => error,
_ => NumericError::NonCanonicalWire,
})?;
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
return Err(NumericError::NonCanonicalWire);
}
Ok(digits)
}
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
}
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
(true, digits)
} else {
(false, text.strip_prefix('+').unwrap_or(text))
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let mut value = 0_i32;
for digit in digits.bytes() {
value = value * 10 + i32::from(digit - b'0');
if value > MAX_DECIMAL_EXPONENT {
return Err(NumericError::DecimalExponent);
}
}
Ok(if negative { -value } else { value })
}
fn decimal_bits(digits: usize) -> u64 {
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
(digits as u64 * 3322).div_ceil(1000).max(1)
}
@@ -0,0 +1,32 @@
[
{
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"expression": {
"Number": {
"denominator": "1",
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
},
"name": "ten_to_300"
},
{
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
"expression": {
"Number": {
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
"numerator": "1"
}
},
"name": "one_over_two_to_4095"
},
{
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
"expression": {
"Number": {
"denominator": "1",
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
}
},
"name": "negative_4096_bit_bound"
}
]
@@ -0,0 +1,240 @@
use super::{NumericError, NumericLimit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Every event is charged once per dynamic occurrence, before entering it.
///
/// A Call expression therefore charges Expression and CalculationEntry; a
/// Property read charges Expression and PropertyBinding. List element visits
/// are additional to evaluation of their expressions. Only executed branches
/// are charged. Targets are sorted by category (properties, then checks), then
/// stable UUID bytes, by the integrating evaluator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvaluationStep {
Target,
Expression,
CalculationEntry,
PropertyBinding,
ListElement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumericOperation {
ParseWire,
ParseLiteral,
Add,
Subtract,
Multiply,
Divide,
Compare,
Negate,
Absolute,
Reciprocal,
Power,
KindAssertion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkResource {
DynamicEvents,
NumericUnits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkSite {
Evaluation(EvaluationStep),
Numeric(NumericOperation),
}
/// Failed reservations consume no partial charge. The first failure is latched.
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
pub struct WorkExhaustion {
pub resource: WorkResource,
pub used: u64,
pub requested: u64,
pub limit: u64,
pub site: WorkSite,
}
/// Provisional calibration ceilings, not measured execution-time guarantees.
///
/// Effective limits, profile identity, capture, targets and context must all be
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
pub struct WorkLimits {
dynamic_events: u64,
numeric_units: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkLimitsWire {
dynamic_events: u64,
numeric_units: u64,
}
impl WorkLimits {
pub const REFERENCE: Self = Self {
dynamic_events: 1_000_000,
numeric_units: 50_000_000,
};
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
if dynamic_events > Self::REFERENCE.dynamic_events
|| numeric_units > Self::REFERENCE.numeric_units
{
return Err(NumericError::InvalidWorkLimits);
}
Ok(Self {
dynamic_events,
numeric_units,
})
}
pub fn dynamic_events(self) -> u64 {
self.dynamic_events
}
pub fn numeric_units(self) -> u64 {
self.numeric_units
}
}
impl TryFrom<WorkLimitsWire> for WorkLimits {
type Error = NumericError;
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
Self::new(value.dynamic_events, value.numeric_units)
}
}
impl From<WorkLimits> for WorkLimitsWire {
fn from(value: WorkLimits) -> Self {
Self {
dynamic_events: value.dynamic_events,
numeric_units: value.numeric_units,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkUsage {
pub dynamic_events: u64,
pub numeric_units: u64,
}
/// Intentionally neither Clone nor Default: pass the same mutable meter through
/// every target, numeric operation and expanded call in one evaluation request.
///
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
/// mark already collected causes as incomplete. Replaying an aggregate cache
/// cost is insufficient if it changes the exact exhaustion location; initially
/// retain logical traversal even when numerical values come from a cache.
#[derive(Debug)]
pub struct WorkMeter {
limits: WorkLimits,
usage: WorkUsage,
exhausted: Option<WorkExhaustion>,
}
impl WorkMeter {
pub fn new(limits: WorkLimits) -> Self {
Self {
limits,
usage: WorkUsage::default(),
exhausted: None,
}
}
pub fn limits(&self) -> WorkLimits {
self.limits
}
pub fn usage(&self) -> WorkUsage {
self.usage
}
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
self.exhausted.as_ref()
}
pub fn ensure_active(&self) -> Result<(), NumericError> {
match &self.exhausted {
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
None => Ok(()),
}
}
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
}
pub(super) fn numeric(
&mut self,
operation: NumericOperation,
units: u64,
) -> Result<(), NumericError> {
self.reserve(
WorkResource::NumericUnits,
units,
WorkSite::Numeric(operation),
)
}
fn reserve(
&mut self,
resource: WorkResource,
requested: u64,
site: WorkSite,
) -> Result<(), NumericError> {
self.ensure_active()?;
let (used, limit) = match resource {
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
};
// Subtraction is safe: only successful reservations update usage.
if requested > limit - used {
let failure = WorkExhaustion {
resource,
used,
requested,
limit,
site,
};
self.exhausted = Some(failure.clone());
return Err(NumericError::Limit(NumericLimit::Work(failure)));
}
match resource {
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
WorkResource::NumericUnits => self.usage.numeric_units += requested,
}
Ok(())
}
}
// Logical 64-bit words are platform independent. These are deterministic
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
pub(super) fn words(bits: u64) -> u64 {
bits.max(1).div_ceil(64)
}
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits) * words(b_bits)
}
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits.max(b_bits))
}
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
let width = words(n_bits.max(d_bits));
// 64*w² for GCD plus 2*w² for the two quotient operations.
1 + 66 * width * width
}
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
1 + digits as u64 * words(estimated_bits)
}
@@ -0,0 +1,385 @@
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
use std::cmp::Ordering;
/// K is supplied by the common resolver's verified nominal compatibility
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
/// Exact definition references and pins remain the caller's provenance.
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
/// comparing an unbounded rooted contract graph is resolver admission work and
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
/// not enforce this resource contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedKind<K> {
key: K,
dimension: Dimension,
difference: Option<K>,
}
impl<K> ResolvedKind<K> {
pub fn linear(key: K, dimension: Dimension) -> Self {
Self {
key,
dimension,
difference: None,
}
}
pub fn key(&self) -> &K {
&self.key
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
pub fn is_affine(&self) -> bool {
self.difference.is_some()
}
}
impl<K: Clone + Eq> ResolvedKind<K> {
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
if difference.is_affine() || key == difference.key {
return Err(NumericError::DifferenceKind);
}
Ok(Self {
key,
dimension: difference.dimension,
difference: Some(difference.key.clone()),
})
}
pub fn difference_kind(&self) -> Option<Self> {
self.difference
.as_ref()
.map(|key| Self::linear(key.clone(), self.dimension))
}
}
/// Canonical coordinate = magnitude * scale + offset.
/// Unit symbols and display preferences are outside this numeric representation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit<K> {
kind: ResolvedKind<K>,
scale: Exact,
offset: Exact,
}
impl<K: Clone + Eq> Unit<K> {
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
if !scale.is_positive() {
return Err(NumericError::UnitScale);
}
if !kind.is_affine() && !offset.is_zero() {
return Err(NumericError::LinearOffset);
}
Ok(Self {
kind,
scale,
offset,
})
}
pub fn kind(&self) -> &ResolvedKind<K> {
&self.kind
}
pub fn scale(&self) -> &Exact {
&self.scale
}
pub fn offset(&self) -> &Exact {
&self.offset
}
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
/// A mathematically cancelling wider intermediate still reports NumericLimit;
/// replacing this path with a fused operation requires a new work profile.
pub fn to_canonical(
&self,
magnitude: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
magnitude
.multiply(&self.scale, meter)?
.add(&self.offset, meter)
}
/// Fixed subtract-then-divide path; never rounds a coordinate.
pub fn from_canonical(
&self,
coordinate: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
coordinate
.subtract(&self.offset, meter)?
.divide(&self.scale, meter)
}
pub fn convert(
&self,
magnitude: &Exact,
target: &Self,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if self.kind != target.kind {
return Err(NumericError::IncompatibleTypes);
}
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
}
}
/// Internal numerical types. Boolean/list/check semantics belong to the common
/// expression evaluator. Anonymous zero dimension normalizes to Number.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NumericType<K> {
Number,
Named(ResolvedKind<K>),
Anonymous(Dimension),
}
impl<K> NumericType<K> {
pub fn dimension(&self) -> Dimension {
match self {
Self::Number => Dimension::ZERO,
Self::Named(kind) => kind.dimension,
Self::Anonymous(dimension) => *dimension,
}
}
pub fn is_affine(&self) -> bool {
matches!(self, Self::Named(kind) if kind.is_affine())
}
pub fn anonymous(dimension: Dimension) -> Self {
if dimension.is_dimensionless() {
Self::Number
} else {
Self::Anonymous(dimension)
}
}
pub fn normalized(self) -> Self {
match self {
Self::Anonymous(dimension) => Self::anonymous(dimension),
other => other,
}
}
}
impl<K: Clone + Eq> NumericType<K> {
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
use NumericType::{Anonymous, Named, Number};
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
match (&left, &right) {
(Number, Number) => Ok(Number),
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
(None, None) if a == b => Ok(Named(a.clone())),
(Some(difference), Some(_)) if subtract && a == b => {
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
}
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
Ok(Named(a.clone()))
}
(None, Some(difference))
if !subtract && difference == &a.key && a.dimension == b.dimension =>
{
Ok(Named(b.clone()))
}
_ => Err(NumericError::IncompatibleTypes),
},
_ => Err(NumericError::IncompatibleTypes),
}
}
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
use NumericType::Number;
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
if left.is_affine() || right.is_affine() {
return Err(NumericError::AffineOperation);
}
match (&left, &right) {
(Number, Number) => Ok(Number),
(_, Number) => Ok(left),
(Number, _) if !divide => Ok(right),
_ => Ok(Self::anonymous(if divide {
left.dimension().checked_quotient(right.dimension())?
} else {
left.dimension().checked_product(right.dimension())?
})),
}
}
pub fn unary_result(&self) -> Result<Self, NumericError> {
if self.is_affine() {
Err(NumericError::AffineOperation)
} else {
Ok(self.clone().normalized())
}
}
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(match exponent {
0 => Self::Number,
1 => self.clone().normalized(),
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
})
}
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
if self.clone().normalized() == rhs.clone().normalized() {
Ok(())
} else {
Err(NumericError::IncompatibleTypes)
}
}
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
if target.is_affine()
|| matches!(self, Self::Named(_))
|| self.dimension() != target.dimension
{
Err(NumericError::KindAssertion)
} else {
Ok(Self::Named(target.clone()))
}
}
}
/// A canonical rational coordinate with an internal numerical type.
///
/// Derived Eq compares stored representation. Formal equality/ordering must call
/// compare(), which rejects mismatched nominal kinds instead of treating them
/// as a false predicate. Public property output must still check its declared
/// type and anchor the result to that declaration's exact definition reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumericValue<K> {
value_type: NumericType<K>,
coordinate: Exact,
}
impl<K: Clone + Eq> NumericValue<K> {
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
Self {
value_type: value_type.normalized(),
coordinate,
}
}
pub fn number(coordinate: Exact) -> Self {
Self::new(NumericType::Number, coordinate)
}
pub fn from_unit(
magnitude: &Exact,
unit: &Unit<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
Ok(Self::new(
NumericType::Named(unit.kind.clone()),
unit.to_canonical(magnitude, meter)?,
))
}
pub fn value_type(&self) -> &NumericType<K> {
&self.value_type
}
pub fn coordinate(&self) -> &Exact {
&self.coordinate
}
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
return Err(NumericError::IncompatibleTypes);
}
unit.from_canonical(&self.coordinate, meter)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, false)?;
Ok(Self::new(
output,
self.coordinate.add(&rhs.coordinate, meter)?,
))
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, true)?;
Ok(Self::new(
output,
self.coordinate.subtract(&rhs.coordinate, meter)?,
))
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self
.value_type
.multiplicative_result(&rhs.value_type, false)?;
Ok(Self::new(
output,
self.coordinate.multiply(&rhs.coordinate, meter)?,
))
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self
.value_type
.multiplicative_result(&rhs.value_type, true)?;
Ok(Self::new(
output,
self.coordinate.divide(&rhs.coordinate, meter)?,
))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.negated(meter)?))
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.absolute(meter)?))
}
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.power_result(exponent)?;
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
meter.ensure_active()?;
self.value_type.comparison_compatible(&rhs.value_type)?;
self.coordinate.compare(&rhs.coordinate, meter)
}
pub fn as_kind(
&self,
target: &ResolvedKind<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.assertion_result(target)?;
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
Ok(Self::new(output, self.coordinate.clone()))
}
}
@@ -0,0 +1,804 @@
use super::*;
use num_bigint::BigInt;
use num_traits::One;
use std::cmp::Ordering;
fn meter() -> WorkMeter {
WorkMeter::new(WorkLimits::REFERENCE)
}
fn exact(n: impl ToString, d: impl ToString) -> Exact {
Exact::from_wire(
&ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
},
&mut meter(),
)
.unwrap()
}
fn source(text: &str) -> Exact {
Exact::parse_literal(text, &mut meter()).unwrap()
}
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
assert_eq!(
value.to_wire(),
ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
}
);
}
// Independent bounded i128 oracle. It does not call the production Exact/GCD
// implementation when deriving expected arithmetic or ordering.
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
assert_ne!(d, 0);
if d < 0 {
n = -n;
d = -d;
}
let (mut a, mut b) = (n.abs(), d);
while b != 0 {
(a, b) = (b, a % b);
}
(n / a, d / a)
}
#[test]
fn arithmetic_matches_independent_small_integer_oracle() {
for a in [-5_i128, -2, 0, 1, 4] {
for b in [1_i128, 2, 3, 7] {
for c in [-5_i128, -2, 0, 1, 4] {
for d in [1_i128, 2, 3, 7] {
let (an, ad) = small_fraction(a, b);
let (bn, bd) = small_fraction(c, d);
let left = exact(an, ad);
let right = exact(bn, bd);
let mut work = meter();
let (n, den) = small_fraction(a * d + c * b, b * d);
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * d - c * b, b * d);
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * c, b * d);
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
if c != 0 {
let (n, den) = small_fraction(a * d, b * c);
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
} else {
assert_eq!(
left.divide(&right, &mut work),
Err(NumericError::DivisionByZero)
);
}
assert_eq!(
left.compare(&right, &mut work).unwrap(),
(a * d).cmp(&(c * b))
);
}
}
}
}
}
#[test]
fn decimals_are_exact_and_source_ratios_reduce() {
assert_fraction(
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
3,
10,
);
assert_fraction(&source("0.50"), 1, 2);
assert_fraction(&source("12.50e-2"), 1, 8);
assert_fraction(&source("1e+3"), 1000, 1);
assert_fraction(&source("0001.2500"), 5, 4);
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
assert_eq!(source("42").canonical_literal(), "42");
}
#[test]
fn powers_and_signs_have_explicit_domains() {
let x = exact(-2, 3);
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
let zero = Exact::from_integer(0);
assert_eq!(
zero.power(0, &mut meter()),
Err(NumericError::ZeroPowerZero)
);
assert_eq!(
zero.power(-1, &mut meter()),
Err(NumericError::DivisionByZero)
);
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
assert_eq!(
x.power(i8::MIN, &mut meter()),
Err(NumericError::IntegerPower)
);
}
#[test]
fn canonical_wire_rejects_both_component_noncanonical_forms() {
for (n, d) in [
("+1", "1"),
("01", "1"),
("-0", "1"),
("1", "+1"),
("1", "-1"),
("1", "01"),
("2", "4"),
("0", "3"),
("", "1"),
("1", ""),
("1.0", "1"),
("1", "1.0"),
("1e1", "1"),
("1", "1e1"),
(" 1", "1"),
("1", "1 "),
] {
assert!(
Exact::from_wire(
&ExactWire {
numerator: n.into(),
denominator: d.into()
},
&mut meter(),
)
.is_err(),
"accepted {n}/{d}"
);
}
assert_eq!(
Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "0".into()
},
&mut meter()
),
Err(NumericError::ZeroDenominator)
);
assert_fraction(&exact(0, 1), 0, 1);
assert_fraction(&exact(-1, 2), -1, 2);
}
#[test]
fn serde_uses_closed_canonical_string_wire() {
let value = exact(-3, 7);
let encoded = serde_json::to_value(&value).unwrap();
assert_eq!(
encoded,
serde_json::json!({"numerator":"-3","denominator":"7"})
);
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
for invalid in [
r#"{"numerator":1,"denominator":"2"}"#,
r#"{"numerator":"1","denominator":2}"#,
r#"{"numerator":"1","denominator":"2","other":0}"#,
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
r#"{"numerator":"1"}"#,
r#"{"numerator":"2","denominator":"4"}"#,
] {
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
}
}
#[test]
fn literal_reader_does_not_fold_expression_unary_minus() {
assert_eq!(
Exact::parse_literal("-2", &mut meter()),
Err(NumericError::InvalidLiteral)
);
let negative_literal = source("ratio(-2, 1)");
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
// Equality of coordinates does not claim AST equality. The expression
// parser must preserve UnaryNegate independently of its operand literal.
assert_eq!(source("2").canonical_literal(), "2");
}
#[test]
fn literal_syntax_and_decimal_exponent_are_bounded() {
for invalid in [
"",
"-",
"+1",
".5",
"1.",
"1.2.3",
"1e",
"1ee2",
"1e+-2",
"1_000",
"NaN",
"Infinity",
"1/2",
"ratio(1)",
"ratio(1,2,3)",
"ratio(1,-2)",
"ratio(+1,2)",
"ratio(1,2) + 1",
"12",
] {
assert!(
Exact::parse_literal(invalid, &mut meter()).is_err(),
"{invalid}"
);
}
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
assert_eq!(
Exact::parse_literal(invalid, &mut meter()),
Err(NumericError::DecimalExponent)
);
}
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
assert_eq!(
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
assert_eq!(
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
}
#[test]
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
let packets: serde_json::Value =
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
for packet in packets.as_array().unwrap() {
let wire: ExactWire =
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
let spelling = packet["canonical_source"].as_str().unwrap();
assert_eq!(expected.canonical_literal(), spelling);
assert_eq!(
Exact::parse_literal(spelling, &mut meter()).unwrap(),
expected
);
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(
serde_json::from_str::<Exact>(&serialized).unwrap(),
expected
);
assert_eq!(expected.to_wire(), wire);
}
}
#[test]
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
let n = (BigInt::one() << 4096_usize) - 1_u8;
let d = &n - 1_u8;
let value = exact(&n, &d);
let result = value.add(&value, &mut meter()).unwrap();
assert_fraction(&result, &n, &d / 2_u8);
}
#[test]
fn cross_products_cancel_before_reduced_component_limit() {
let n = BigInt::one() << 4095_usize;
let d = &n - 1_u8;
let left = exact(&n, &d);
let right = exact(&d, &n);
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
assert_fraction(
&left
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
.unwrap(),
0,
1,
);
}
#[test]
fn both_reduced_component_overflows_are_reported() {
let n = BigInt::one() << 4095_usize;
let mut work = meter();
let failure = exact(&n, 1)
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
..
})
));
assert!(work.exhaustion().is_none());
let failure = exact(1, &n)
.divide(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Denominator,
bits: 4097,
..
})
));
// Value overflow is a nonterminal supported-operation failure; fuel
// exhaustion, in contrast, terminates the entire request.
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
}
#[test]
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
let oversized = (BigInt::one() << 4096_usize).to_string();
for wire in [
ExactWire {
numerator: oversized.clone(),
denominator: "1".into(),
},
ExactWire {
numerator: "1".into(),
denominator: oversized.clone(),
},
ExactWire {
numerator: oversized.clone(),
denominator: oversized,
},
] {
assert!(matches!(
Exact::from_wire(&wire, &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
));
}
}
#[test]
fn numeric_precharge_precedes_big_integer_result_allocation() {
let n = BigInt::one() << 4095_usize;
let left = exact(&n, 1);
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
let failure = left
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().numeric_units, 0);
assert!(matches!(
work.exhaustion().unwrap().site,
WorkSite::Numeric(NumericOperation::Multiply)
));
// With available fuel the same operation reaches the 4097-bit result check.
assert!(matches!(
left.multiply(&Exact::from_integer(2), &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { .. }))
));
let too_wide_wire = ExactWire {
numerator: (BigInt::one() << 4096_usize).to_string(),
denominator: "1".into(),
};
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
assert!(
Exact::from_wire(&too_wide_wire, &mut parse_work)
.unwrap_err()
.is_terminal_work_exhaustion()
);
}
#[test]
fn provisional_limits_and_effective_limits_round_trip() {
let limits = WorkLimits::new(500, 2000).unwrap();
let encoded = serde_json::to_value(limits).unwrap();
assert_eq!(
encoded,
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
);
assert_eq!(
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
limits
);
assert_eq!(
WorkLimits::new(1_000_001, 0),
Err(NumericError::InvalidWorkLimits)
);
assert_eq!(
WorkLimits::new(0, 50_000_001),
Err(NumericError::InvalidWorkLimits)
);
assert!(
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
.is_err()
);
}
#[test]
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
// One-word addition: three products at 2, one sum at 2,
// normalization at 67 => 75 logical numeric units.
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
assert_eq!(work.usage().numeric_units, 75);
assert!(work.exhaustion().is_none());
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
let at_failure = work.usage();
assert_eq!(
work.exhaustion().unwrap(),
&WorkExhaustion {
resource: WorkResource::NumericUnits,
used: 75,
requested: 3,
limit: 75,
site: WorkSite::Numeric(NumericOperation::Negate),
}
);
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
assert_eq!(
Exact::from_integer(1)
.divide(&Exact::from_integer(0), &mut work)
.unwrap_err(),
first
);
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
first
);
assert_eq!(work.usage(), at_failure);
}
#[test]
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
work.numeric(NumericOperation::Compare, 0).unwrap();
let failure = work
.numeric(NumericOperation::Multiply, u64::MAX)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage(), WorkUsage::default());
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
failure
);
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
assert!(dynamic.visit(EvaluationStep::Target).is_err());
assert_eq!(dynamic.usage(), WorkUsage::default());
}
#[test]
fn multiple_targets_share_one_aggregate_meter() {
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
work.visit(EvaluationStep::Target).unwrap();
work.visit(EvaluationStep::Expression).unwrap();
work.visit(EvaluationStep::Target).unwrap();
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().dynamic_events, 3);
}
// A direct bounded call-tree workload, not an expression interpreter/dialect.
fn expanded_calls(
depth: u8,
x: u64,
work: &mut WorkMeter,
entered: &mut u64,
) -> Result<u64, NumericError> {
work.visit(EvaluationStep::CalculationEntry)?;
*entered += 1;
if depth == 0 {
return Ok(x);
}
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
Ok(left + right)
}
#[test]
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
let mut entered = 0;
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(entered, 10_000);
assert_eq!(work.usage().dynamic_events, 10_000);
assert_eq!(
work.exhaustion().unwrap().site,
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
);
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
let mut entered = 0;
assert_eq!(
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
496
);
assert_eq!(entered, 63);
assert!(bounded.exhaustion().is_none());
}
fn dim(value: [i16; 7]) -> Dimension {
Dimension::new(value).unwrap()
}
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
ResolvedKind::linear(key.to_owned(), dimension)
}
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
}
#[test]
fn dimensions_use_checked_seven_axis_integer_algebra() {
let length = dim([1, 0, 0, 0, 0, 0, 0]);
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
let time = dim([0, 0, 1, 0, 0, 0, 0]);
assert_eq!(
mass.checked_product(length)
.unwrap()
.checked_quotient(time.checked_power(2).unwrap())
.unwrap(),
dim([1, 1, -2, 0, 0, 0, 0])
);
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
assert!(matches!(
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
Err(NumericError::DimensionExponent {
axis: DimensionAxis::Length,
value: 33
})
));
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
assert!(length.checked_power(i8::MIN).is_err());
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
assert_eq!(
serde_json::to_value(time).unwrap(),
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
);
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
}
}
#[test]
fn duration_unit_coordinates_are_exact_and_nominal() {
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
let total = receive.add(&dispatch, &mut meter()).unwrap();
assert_fraction(total.coordinate(), 7, 4);
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
let different = Unit::new(
linear("different-kind", duration.dimension()),
source("1"),
source("0"),
)
.unwrap();
assert_eq!(
ms.convert(&source("750"), &different, &mut meter()),
Err(NumericError::IncompatibleTypes)
);
let inconsistent = Unit::new(
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
source("1"),
source("0"),
)
.unwrap();
assert!(
ms.convert(&source("1"), &inconsistent, &mut meter())
.is_err()
);
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
assert_eq!(
Unit::new(duration, source("1"), source("1")).unwrap_err(),
NumericError::LinearOffset
);
}
#[test]
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
let fahrenheit = Unit::new(
point.clone(),
source("ratio(5,9)"),
source("ratio(45967,180)"),
)
.unwrap();
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
assert_fraction(
&celsius
.convert(&source("0"), &fahrenheit, &mut meter())
.unwrap(),
32,
1,
);
assert_fraction(
&fahrenheit
.convert(&source("70"), &celsius, &mut meter())
.unwrap(),
190,
9,
);
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
let difference = p20.subtract(&p10, &mut meter()).unwrap();
assert_eq!(
difference.value_type(),
&NumericType::Named(interval.clone())
);
assert_fraction(
&difference
.display_in(&kelvin_interval, &mut meter())
.unwrap(),
10,
1,
);
let delta5 = named(5, &interval);
let p25 = p20.add(&delta5, &mut meter()).unwrap();
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
assert!(p20.add(&p10, &mut meter()).is_err());
assert!(delta5.subtract(&p20, &mut meter()).is_err());
assert_eq!(
p20.power(1, &mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(
p20.negated(&mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
}
#[test]
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
// The fixed multiply stage produces 2^4096, so it must fail before offset
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
let large = BigInt::one() << 4095_usize;
let magnitude = exact(&large, 1);
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
let mut work = meter();
for outcome in [
unit.to_canonical(&magnitude, &mut work),
unit.from_canonical(&magnitude, &mut work),
] {
assert!(matches!(
outcome,
Err(NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
limit: MAX_COMPONENT_BITS,
..
}))
));
}
assert!(work.exhaustion().is_none());
let half = exact(&large >> 1_usize, 1);
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
assert_eq!(
unit.from_canonical(&Exact::from_integer(0), &mut work)
.unwrap(),
half
);
}
#[test]
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let a = named(3, &force)
.multiply(&named(4, &length), &mut meter())
.unwrap();
let b = named(2, &force)
.multiply(&named(7, &length), &mut meter())
.unwrap();
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
let sum = a.add(&b, &mut meter()).unwrap();
let result = sum.as_kind(&energy, &mut meter()).unwrap();
assert_fraction(result.coordinate(), 26, 1);
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
assert!(result.add(&sum, &mut meter()).is_err());
assert_eq!(
result.as_kind(&energy, &mut meter()),
Err(NumericError::KindAssertion)
);
}
#[test]
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
let result = named(2, &mass)
.multiply(&named(3, &length), &mut meter())
.unwrap()
.divide(&named(2, &time), &mut meter())
.unwrap()
.as_kind(&momentum, &mut meter())
.unwrap();
assert_fraction(result.coordinate(), 3, 1);
let scalar = NumericValue::number(source("2"));
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
assert_fraction(scaled.coordinate(), 6, 1);
assert_eq!(
named(3, &time)
.divide(&scalar, &mut meter())
.unwrap()
.value_type(),
&NumericType::Named(time.clone())
);
assert_eq!(
scalar
.divide(&named(3, &time), &mut meter())
.unwrap()
.value_type(),
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
);
}
#[test]
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let torque = linear("torque", energy.dimension());
let left = named(7, &energy);
let right = named(7, &torque);
assert!(left.add(&right, &mut meter()).is_err());
assert!(left.compare(&right, &mut meter()).is_err());
let ratio = left.divide(&right, &mut meter()).unwrap();
assert_eq!(ratio.value_type(), &NumericType::Number);
assert_fraction(ratio.coordinate(), 1, 1);
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
assert_eq!(
asserted.value_type(),
&NumericType::Named(named_ratio.clone())
);
assert!(asserted.add(&ratio, &mut meter()).is_err());
assert_eq!(
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
NumericValue::number(source("1"))
);
}
#[test]
fn numerical_power_types_and_dimension_overflow_are_checked() {
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let value = named(2, &length);
assert_eq!(
value.power(0, &mut meter()).unwrap().value_type(),
&NumericType::Number
);
assert_eq!(
value.power(1, &mut meter()).unwrap().value_type(),
value.value_type()
);
assert_eq!(
value.power(-2, &mut meter()).unwrap().value_type(),
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
);
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
assert!(
named(1, &large_dimension)
.multiply(&value, &mut meter())
.is_err()
);
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
}
+216 -274
View File
@@ -1,6 +1,12 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
mod raw_tokens;
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
// Staged syntax foundation only. Public source8 admission waits for complete
// declaration lowering, source updates and the root-owned resource gate.
#[cfg(test)]
mod analysis_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -60,7 +66,8 @@ impl ParseLimits {
let graph = project
.elements
.len()
.saturating_add(project.relations.len());
.saturating_add(project.relations.len())
.saturating_add(project.definitions.len());
let records = graph
.saturating_add(1)
.saturating_add(project.diagrams.len())
@@ -79,7 +86,8 @@ impl ParseLimits {
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
.saturating_add(project.traces.len());
.saturating_add(project.traces.len())
.saturating_add(crate::analysis::nested_record_count(project));
let steps = project
.elements
.values()
@@ -142,164 +150,10 @@ pub struct Analysis {
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)
raw_tokens::legacy(source)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -626,6 +480,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
cursor: 0,
value_depth: 0,
limits,
#[cfg(test)]
mixed: None,
}
.document()
.map_err(|e| vec![diagnostic(source, e)])
@@ -1060,6 +916,8 @@ pub(crate) fn parse_unit(
cursor: 0,
value_depth: 0,
limits,
#[cfg(test)]
mixed: None,
}
.unit(entry, true)
.map_err(|e| Box::new(diagnostic(source, e)))
@@ -1151,31 +1009,52 @@ fn edit_positions(
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();
edit_positions_from_tokens(source, &unit, |at| tokens[at], tokens.len(), None)
}
fn edit_positions_from_tokens(
source: &str,
unit: &SourceUnit,
token: impl Fn(usize) -> Lexeme,
count: usize,
value_ends: Option<&BTreeMap<usize, usize>>,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let mut ends: Vec<_> = (0..count).map(|at| token(at).span.end).collect();
let mut stack = Vec::new();
for (index, token) in tokens.iter().enumerate() {
for index in 0..count {
let token = token(index);
match token.text(source) {
"{" | "[" => stack.push(index),
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
_ => {}
}
}
struct Positions<'a> {
tokens: &'a [Lexeme],
struct Positions<'a, F> {
token_at: F,
ends: Vec<usize>,
value_ends: Option<&'a BTreeMap<usize, usize>>,
}
impl Positions<'_> {
impl<F: Fn(usize) -> Lexeme> Positions<'_, F> {
fn token(&self, start: usize) -> usize {
self.tokens
.partition_point(|token| token.span.start < start)
let mut low = 0;
let mut high = self.ends.len();
while low < high {
let middle = low + (high - low) / 2;
if (self.token_at)(middle).span.start < start {
low = middle + 1;
} else {
high = middle;
}
}
low
}
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;
let start = (self.token_at)(self.token(value.span.start) - 1).span.start;
let end = (self.token_at)(self.token(value.span.end)).span.end;
(
key.clone(),
EditField {
@@ -1190,10 +1069,15 @@ fn edit_positions(
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],
},
end: self
.value_ends
.and_then(|ends| ends.get(&ast.offset()).copied())
.unwrap_or_else(|| match ast {
Ast::Reference(..) | Ast::HistoricalUuid(..) => {
(self.token_at)(index + 1).span.end
}
_ => self.ends[index],
}),
};
let kind = match ast {
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
@@ -1208,11 +1092,12 @@ fn edit_positions(
}
}
let positions = Positions {
tokens: &tokens,
token_at: &token,
ends,
value_ends,
};
unit.declarations
.into_iter()
.iter()
.map(|declaration| {
if declaration.id_insertion.is_some() {
return Err(Box::new(diagnostic(
@@ -1232,8 +1117,8 @@ fn edit_positions(
let start = positions.token(declaration.span.start);
let header = roles
.iter()
.zip(&tokens[start..])
.map(|(role, token)| (*role, token.span))
.zip(start..count)
.map(|(role, at)| (*role, token(at).span))
.collect();
Ok((
declaration.id,
@@ -1260,10 +1145,35 @@ struct Parser<'a> {
cursor: usize,
value_depth: usize,
limits: ParseLimits,
#[cfg(test)]
mixed: Option<analysis_source::mixed::State<'a>>,
}
impl Parser<'_> {
fn token(&self, index: usize) -> Option<Lexeme> {
#[cfg(test)]
if let Some(mixed) = &self.mixed {
return mixed.token(index);
}
self.tokens.get(index).copied()
}
fn current(&self) -> Lexeme {
self.tokens[self.cursor.min(self.tokens.len() - 1)]
self.token(self.cursor).unwrap_or(Lexeme {
class: TokenClass::Punctuation,
span: Span {
start: self.source.len(),
end: self.source.len(),
},
})
}
fn syntax_entry(&mut self) -> Result<(), Error> {
#[cfg(test)]
{
let span = self.current().span;
if let Some(mixed) = &mut self.mixed {
mixed.entry(span)?;
}
}
Ok(())
}
fn text(&self) -> &str {
self.current().text(self.source)
@@ -1300,12 +1210,20 @@ impl Parser<'_> {
}
fn optional_id(&mut self) -> Result<Id, Error> {
if self.text() != "id" {
#[cfg(test)]
if self.mixed.is_some() {
return Err(self.error("Private mixed syntax requires explicit IDs."));
}
return Ok(Id::new_v4());
}
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if !has_uuid_header_shape(&value) {
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
@@ -1336,6 +1254,7 @@ impl Parser<'_> {
Ok(fields)
}
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
self.syntax_entry()?;
let key = self.text().to_owned();
if !matches!(
self.current().class,
@@ -1346,15 +1265,19 @@ impl Parser<'_> {
self.cursor += 1;
let start = self.current().span.start;
let value = self.value()?;
let end = self.tokens[self.cursor - 1].span.end;
let end = self
.token(self.cursor - 1)
.expect("consumed token")
.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)?;
let alias = self.token(self.cursor + 1)?;
let following = self.token(self.cursor + 2)?;
// A node's ordinary `diagram @view;` field is not a declaration.
if !crate::valid_alias(alias.text(self.source)) {
return None;
@@ -1369,14 +1292,33 @@ impl Parser<'_> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
}
self.syntax_entry()?;
self.value_depth += 1;
let result = self.value_inner();
self.value_depth -= 1;
#[cfg(test)]
if let Ok(ast) = &result {
let end = self
.token(self.cursor - 1)
.expect("consumed value")
.span
.end;
if let Some(mixed) = &mut self.mixed {
mixed.value_ends.insert(ast.offset(), end);
}
}
result
}
fn value_inner(&mut self) -> Result<Ast, Error> {
let token = self.current();
let offset = token.span.start;
#[cfg(test)]
if let Some(mixed) = &self.mixed
&& (token.class == TokenClass::Number || matches!(self.text(), "-" | "+"))
{
let value = mixed.ordinary_number(&mut self.cursor)?;
return Ok(Ast::Literal(value, offset));
}
match self.text() {
"uuid" => {
self.cursor += 1;
@@ -1474,16 +1416,31 @@ impl Parser<'_> {
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.")
);
#[cfg(test)]
let mixed_mode = self.mixed.is_some();
#[cfg(not(test))]
let mixed_mode = false;
if if mixed_mode {
self.text() != "8"
} else {
!matches!(self.text(), "3" | "4" | "5" | "6" | "7")
} {
return Err(self.error(if mixed_mode {
"Private mixed syntax requires arcadia 8."
} else {
"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")?;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.record(false, Span { start, end: start })?;
}
self.syntax_entry()?;
let alias = self.identifier()?;
let name = self.quoted()?;
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
@@ -1497,6 +1454,7 @@ impl Parser<'_> {
alias,
name,
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1523,6 +1481,14 @@ impl Parser<'_> {
let mut open: Vec<usize> = Vec::new();
loop {
let offset = self.current().span.start;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed
&& mixed.typed_header(self.cursor, open.is_empty())
{
let owner = open.last().map(|&at| declarations[at].id);
mixed.parse_typed(&mut self.cursor, owner)?;
continue;
}
if let Some(&owner) = open.last() {
if self.text() == "}" {
declarations[owner].body.end = offset;
@@ -1566,6 +1532,14 @@ impl Parser<'_> {
if !includes_allowed {
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
}
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.include(Span {
start: offset,
end: offset,
})?;
}
self.syntax_entry()?;
self.cursor += 1;
let path = self.quoted()?;
self.take(";")?;
@@ -1573,7 +1547,11 @@ impl Parser<'_> {
path,
Span {
start: offset,
end: self.tokens[self.cursor - 1].span.end,
end: self
.token(self.cursor - 1)
.expect("consumed token")
.span
.end,
},
));
continue;
@@ -1585,20 +1563,40 @@ impl Parser<'_> {
if entry_header.description.is_some() {
return Err(self.error("Duplicate project description."));
}
self.syntax_entry()?;
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,
end: self
.token(self.cursor - 1)
.expect("consumed token")
.span
.end,
});
header = Some(entry_header);
continue;
}
#[cfg(test)]
if self.mixed.is_some() && analysis_source::mixed::long_analysis_word(self.text()) {
return Err(self.error("Use the short source8 analysis declaration word."));
}
self.syntax_entry()?;
let word = self.text().to_owned();
let kind = declaration_kind(&word)
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.record(
matches!(kind, DeclKind::Element(..) | DeclKind::Relation(..)),
Span {
start: offset,
end: offset,
},
)?;
}
graph_count += usize::from(matches!(
kind,
DeclKind::Element(..) | DeclKind::Relation(..)
@@ -1626,6 +1624,10 @@ impl Parser<'_> {
let body_start = self.current().span.end;
self.take("{")?;
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.lexical_owners.push(open.last().copied());
}
declarations.push(Declaration {
kind,
alias,
@@ -1717,6 +1719,9 @@ pub(crate) fn resolve_declarations(
}
parent = Some(owner);
}
if kind.is_analysis() {
return Err(conversion_error("Analysis records require the source8 authoring implementation.".into()));
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1895,10 +1900,7 @@ pub(crate) fn resolve_declarations(
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)
let migrated = source3_bridge::from_scratch(project, legacy_elements)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
@@ -1946,13 +1948,15 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if matches!(project.schema_version, 4..=6) {
if matches!(project.schema_version, 4..=7) {
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 {
} else if project.schema_version == 6 {
crate::persistence::migrate_v6(project, limits)
} else {
crate::persistence::migrate_v7(project, limits)
})
.map_err(|error| {
(
@@ -2033,49 +2037,8 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// 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))
@@ -2084,21 +2047,17 @@ fn declaration_outline(
// 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];
@@ -2123,16 +2082,7 @@ fn declaration_outline(
}
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")
@@ -2140,51 +2090,39 @@ fn declaration_outline(
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();
}
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);
let mut end = significant
.get(j)
.map_or(alias_token.span.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,
},
declarations.push(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))
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2579,7 +2517,11 @@ pub(crate) mod legacy {
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) {
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
@@ -0,0 +1,116 @@
//! Unresolved typed source8 syntax, staged for the complete source adapter.
//! This module does not admit a Project or enable public source8.
mod budget;
mod cursor;
mod declarations;
mod index;
mod lex;
mod lower;
pub(super) mod mixed;
mod syntax;
mod update;
mod write;
#[cfg(test)]
mod model_oracle;
use super::Span;
use crate::analysis::{BinaryOp, UnaryOp};
use crate::analysis_numeric::Exact;
use budget::Budget;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub span: Span,
pub message: String,
}
fn error(span: Span, message: impl Into<String>) -> Error {
Error {
span,
message: message.into(),
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Reference {
pub name: String,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Argument {
pub parameter: Reference,
pub value: Expression,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Expression {
pub kind: ExpressionKind,
pub span: Span,
pub depth: usize,
pub grouped: bool,
/// Original numeric tokens, including constructor punctuation, excluding
/// trivia. The full span retains intervening comments and spelling.
pub literal: Option<LiteralSyntax>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct LiteralSyntax {
pub span: Span,
pub tokens: Vec<Span>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ExpressionKind {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: Reference,
},
Property(Reference),
Parameter(Reference),
List(Vec<Expression>),
Unary {
op: UnaryOp,
operand: Box<Expression>,
},
Binary {
op: BinaryOp,
left: Box<Expression>,
right: Box<Expression>,
},
If {
condition: Box<Expression>,
when_true: Box<Expression>,
when_false: Box<Expression>,
},
Call {
definition: Reference,
arguments: Vec<Argument>,
},
Sum {
values: Box<Expression>,
initial: Box<Expression>,
},
Power {
base: Box<Expression>,
exponent: i8,
},
AsKind {
kind: Reference,
value: Box<Expression>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum Binding {
Unbound(Span),
Expression(Expression),
}
pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = cursor::Cursor::new(source, &tokens, budget);
let binding = syntax::binding(&mut cursor)?;
cursor.end()?;
Ok(binding)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,162 @@
//! Candidate limits for measured adoption; one owner spans all parsed files.
use super::{Error, Span, error};
use crate::analysis_numeric::{Exact, WorkLimits, WorkMeter};
use crate::language::ParseLimits;
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub frames: usize,
pub numeric_units: u64,
}
impl Limits {
pub fn source8(caller: ParseLimits) -> Self {
let bytes = caller.max_source_bytes.min(64 * 1024 * 1024);
Self {
bytes,
tokens: bytes.min(1_048_576),
entries: caller.max_records.min(262_144),
references: caller.max_step_references.min(500_000),
frames: 256,
numeric_units: 50_000_000,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Usage {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub deepest_frame: usize,
}
pub struct Budget {
limits: Limits,
pub usage: Usage,
numeric: WorkMeter,
failed: Option<Error>,
}
impl Budget {
pub fn new(limits: Limits) -> Result<Self, Error> {
let hard = Limits::source8(ParseLimits::UNLIMITED);
if limits.bytes > hard.bytes
|| limits.tokens > hard.tokens
|| limits.entries > hard.entries
|| limits.references > hard.references
|| limits.frames > hard.frames
|| limits.numeric_units > hard.numeric_units
{
return Err(error(
Span::default(),
"Source8 profile cannot exceed candidate hard limits.",
));
}
Ok(Self {
limits,
usage: Usage::default(),
numeric: WorkMeter::new(
WorkLimits::new(0, limits.numeric_units)
.map_err(|e| error(Span::default(), e.to_string()))?,
),
failed: None,
})
}
pub fn active(&self) -> Result<(), Error> {
self.failed.clone().map_or(Ok(()), Err)
}
fn charge(
&mut self,
used: usize,
amount: usize,
maximum: usize,
span: Span,
name: &str,
) -> Result<(), Error> {
self.active()?;
if amount > maximum.saturating_sub(used) {
let failure = error(span, format!("Source8 {name} limit exceeded."));
self.failed = Some(failure.clone());
return Err(failure);
}
Ok(())
}
pub fn bytes(&mut self, amount: usize, span: Span) -> Result<(), Error> {
self.charge(self.usage.bytes, amount, self.limits.bytes, span, "bytes")?;
self.usage.bytes += amount;
Ok(())
}
pub fn token(&mut self, span: Span) -> Result<(), Error> {
self.charge(self.usage.tokens, 1, self.limits.tokens, span, "tokens")?;
self.usage.tokens += 1;
Ok(())
}
pub fn entry(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.entries,
1,
self.limits.entries,
span,
"syntax entries",
)?;
self.usage.entries += 1;
Ok(())
}
pub fn reference(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.references,
1,
self.limits.references,
span,
"references",
)?;
self.usage.references += 1;
Ok(())
}
pub fn frame(&mut self, depth: usize, span: Span) -> Result<(), Error> {
self.charge(0, depth, self.limits.frames, span, "syntax frames")?;
self.usage.deepest_frame = self.usage.deepest_frame.max(depth);
Ok(())
}
pub fn exact(&mut self, spelling: &str, span: Span) -> Result<Exact, Error> {
self.active()?;
match Exact::parse_literal(spelling, &mut self.numeric) {
Ok(value) => Ok(value),
Err(cause) => {
let failure = error(span, cause.to_string());
self.failed = Some(failure.clone());
Err(failure)
}
}
}
pub fn numeric_units(&self) -> u64 {
self.numeric.usage().numeric_units
}
/// Check phase allocation bounds without recharging parsed syntax or exact
/// work. S4 must also charge ordinary records through the inherited budget.
pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
if count > self.limits.entries {
return Err(error(span, "Source8 lowering entry limit exceeded."));
}
Ok(())
}
/// Aggregate owned text for an index/update phase, checked before copying.
pub fn index_bytes(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
if count > self.limits.bytes {
return Err(error(span, "Source8 index/output byte limit exceeded."));
}
Ok(())
}
pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
// Parameter declarations are syntax entries, not reference uses.
if count > self.limits.references.saturating_add(self.limits.entries) {
return Err(error(span, "Source8 lowering occurrence limit exceeded."));
}
Ok(())
}
}
@@ -0,0 +1,171 @@
//! A borrowed significant-token cursor. Spans always refer to the full source.
use super::*;
use lex::{Class, Token};
pub(super) struct Cursor<'a, 'b> {
pub source: &'a str,
pub tokens: &'a [Token],
pub cursor: usize,
pub frames: usize,
pub budget: &'b mut Budget,
}
impl<'a, 'b> Cursor<'a, 'b> {
pub(super) fn new(source: &'a str, tokens: &'a [Token], budget: &'b mut Budget) -> Self {
Self {
source,
tokens,
cursor: 0,
frames: 0,
budget,
}
}
}
impl Cursor<'_, '_> {
pub(super) fn span(&self) -> Span {
self.tokens.get(self.cursor).map_or(
Span {
start: self.source.len(),
end: self.source.len(),
},
|token| token.span,
)
}
pub(super) fn text(&self) -> &str {
let span = self.span();
&self.source[span.start..span.end]
}
pub(super) fn take(&mut self, expected: &str) -> Result<Span, Error> {
if self.text() != expected {
return Err(error(self.span(), format!("Expected '{expected}'.")));
}
let span = self.span();
self.cursor += 1;
Ok(span)
}
pub(super) fn end(&self) -> Result<(), Error> {
if self.cursor != self.tokens.len() {
Err(error(
self.span(),
"Unexpected content after analysis expression.",
))
} else {
Ok(())
}
}
pub(super) fn open(&mut self) -> Result<(), Error> {
self.budget.frame(self.frames + 1, self.span())?;
self.frames += 1;
Ok(())
}
pub(super) fn close(&mut self) {
self.frames -= 1;
}
pub(super) fn reference(&mut self) -> Result<Reference, Error> {
self.budget.reference(self.span())?;
self.name()
}
pub(super) fn name(&mut self) -> Result<Reference, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Word)
|| !crate::valid_alias(self.text())
{
return Err(error(span, "Expected a valid reference or parameter name."));
}
let name = self.text().to_owned();
self.cursor += 1;
Ok(Reference { name, span })
}
pub(super) fn quoted(&mut self) -> Result<String, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::String)
{
return Err(error(span, "Expected a quoted string."));
}
let value =
serde_json::from_str(self.text()).map_err(|_| error(span, "Invalid quoted string."))?;
self.cursor += 1;
Ok(value)
}
pub(super) fn last_end(&self) -> usize {
self.tokens[self.cursor - 1].span.end
}
pub(super) fn exact_literal(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
if self.text() == "ratio" {
return self.ratio();
}
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Number)
{
return Err(error(span, "Expected an exact numeric atom or ratio."));
}
let value = self
.budget
.exact(&self.source[span.start..span.end], span)?;
self.cursor += 1;
Ok((
value,
LiteralSyntax {
span,
tokens: vec![span],
},
))
}
pub(super) fn integer(&mut self) -> Result<Span, Error> {
let span = self.span();
let text = self.text();
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
return Err(error(span, "Expected unsigned integer digits."));
}
if text.len() > crate::analysis_numeric::MAX_COMPONENT_DIGITS {
return Err(error(span, "Integer component exceeds digit limit."));
}
self.cursor += 1;
Ok(span)
}
pub(super) fn ratio(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
let first_token = self.cursor;
let start = self.take("ratio")?.start;
self.open()?;
self.take("(")?;
let negative = if self.text() == "-" {
self.cursor += 1;
true
} else {
false
};
let numerator = self.integer()?;
self.take(",")?;
let denominator = self.integer()?;
let end = self.take(")")?.end;
self.close();
let n = &self.source[numerator.start..numerator.end];
let d = &self.source[denominator.start..denominator.end];
let length = 9 + usize::from(negative) + n.len() + d.len();
if length > crate::analysis_numeric::MAX_LITERAL_BYTES {
return Err(error(
Span { start, end },
"Numeric reconstruction exceeds literal byte limit.",
));
}
let normalized = format!("ratio({}{n}, {d})", if negative { "-" } else { "" });
Ok((
self.budget.exact(&normalized, Span { start, end })?,
LiteralSyntax {
span: Span { start, end },
tokens: self.tokens[first_token..self.cursor]
.iter()
.map(|token| token.span)
.collect(),
},
))
}
}
@@ -0,0 +1,635 @@
//! Required typed declaration syntax. No model construction or resolution.
use super::cursor::Cursor;
use super::*;
use crate::{Id, Layer};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeclarationKind {
QuantityKind,
Unit,
Calculation,
Property(Layer),
Check(Layer),
Case(Layer),
}
/// Contextual recognition only: never add these words to the global alias ban.
pub fn declaration_kind(word: &str) -> Option<DeclarationKind> {
use DeclarationKind::*;
Some(match word {
"quantity_kind" => QuantityKind,
"unit" => Unit,
"calculation" => Calculation,
"oa_property" => Property(Layer::Oa),
"sa_property" => Property(Layer::Sa),
"oa_check" => Check(Layer::Oa),
"sa_check" => Check(Layer::Sa),
"oa_analysis" => Case(Layer::Oa),
"sa_analysis" => Case(Layer::Sa),
_ => return None,
})
}
#[derive(Debug, PartialEq, Eq)]
pub struct IdentitySyntax {
/// Missing IDs stay unresolved here. The bundle stabilization owner assigns
/// and inserts an ID once, then reparses the actual returned bytes.
pub value: Option<Id>,
pub keyword: Option<Span>,
pub literal: Option<Span>,
pub insertion: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Header {
pub kind: DeclarationKind,
pub kind_span: Span,
pub alias: Reference,
pub name: String,
pub name_span: Span,
pub identity: IdentitySyntax,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Field<T> {
/// Complete field including its keyword and trailing semicolon.
pub span: Span,
pub keyword: Span,
/// Same meaning as an ordinary top-level field value span.
pub value_span: Span,
pub value: T,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ScalarSyntax {
pub span: Span,
pub kind: ScalarKind,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScalarKind {
Boolean,
Number,
Quantity(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct TypeSyntax {
pub span: Span,
pub scalar: ScalarSyntax,
pub list: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ParameterSyntax {
pub span: Span,
pub name: Reference,
pub identity: IdentitySyntax,
pub value_type: TypeSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionEntry {
pub axis: Span,
pub exponent: Span,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionSyntax {
pub exponents: [i16; 7],
pub entries: Vec<DimensionEntry>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AlgebraSyntax {
Linear,
Affine(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct ExactSyntax {
pub value: Exact,
pub literal: LiteralSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct OverrideSyntax {
pub span: Span,
pub target: Reference,
pub binding: Binding,
pub binding_span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Body {
QuantityKind {
dimension: Field<DimensionSyntax>,
algebra: Field<AlgebraSyntax>,
},
Unit {
kind: Field<Reference>,
scale: Field<ExactSyntax>,
offset: Field<ExactSyntax>,
symbol: Field<String>,
},
Calculation {
parameters: Vec<ParameterSyntax>,
result: Field<TypeSyntax>,
body: Field<Binding>,
},
Property {
subject: Field<Reference>,
value_type: Field<TypeSyntax>,
base: Field<Binding>,
display_unit: Option<Field<Option<Reference>>>,
},
Check {
subject: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Field<Binding>,
},
Case {
subject: Option<Field<Option<Reference>>>,
base: Field<()>,
overrides: Field<Vec<OverrideSyntax>>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Declaration {
pub header: Header,
pub span: Span,
pub body_span: Span,
pub description: Option<Field<String>>,
/// Analysis roots may explicitly spell `parent none`; definitions may not.
pub parent: Option<Field<()>>,
pub body: Body,
}
#[derive(Default)]
struct Fields {
dimension: Option<Field<DimensionSyntax>>,
algebra: Option<Field<AlgebraSyntax>>,
kind: Option<Field<Reference>>,
scale: Option<Field<ExactSyntax>>,
offset: Option<Field<ExactSyntax>>,
symbol: Option<Field<String>>,
result: Option<Field<TypeSyntax>>,
body: Option<Field<Binding>>,
subject: Option<Field<Reference>>,
optional_subject: Option<Field<Option<Reference>>>,
value_type: Option<Field<TypeSyntax>>,
base: Option<Field<Binding>>,
display_unit: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Option<Field<Binding>>,
baseline: Option<Field<()>>,
overrides: Option<Field<Vec<OverrideSyntax>>>,
}
fn required<T>(value: Option<T>, name: &str, at: Span) -> Result<T, Error> {
value.ok_or_else(|| error(at, format!("Missing required field '{name}'.")))
}
impl Fields {
fn finish(
self,
kind: DeclarationKind,
parameters: Vec<ParameterSyntax>,
at: Span,
) -> Result<Body, Error> {
Ok(match kind {
DeclarationKind::QuantityKind => Body::QuantityKind {
dimension: required(self.dimension, "dimension", at)?,
algebra: required(self.algebra, "algebra", at)?,
},
DeclarationKind::Unit => Body::Unit {
kind: required(self.kind, "kind", at)?,
scale: required(self.scale, "scale", at)?,
offset: required(self.offset, "offset", at)?,
symbol: required(self.symbol, "symbol", at)?,
},
DeclarationKind::Calculation => Body::Calculation {
parameters,
result: required(self.result, "result", at)?,
body: required(self.body, "body", at)?,
},
DeclarationKind::Property(_) => Body::Property {
subject: required(self.subject, "subject", at)?,
value_type: required(self.value_type, "type", at)?,
base: required(self.base, "base", at)?,
display_unit: self.display_unit,
},
DeclarationKind::Check(_) => Body::Check {
subject: self.optional_subject,
documented_constraint: self.documented_constraint,
predicate: required(self.predicate, "predicate", at)?,
},
DeclarationKind::Case(_) => Body::Case {
subject: self.optional_subject,
base: required(self.baseline, "base", at)?,
overrides: required(self.overrides, "overrides", at)?,
},
})
}
}
/// Consume exactly one declaration from already charged full-file tokens.
/// `version` is inherited from the caller's entry policy; fragments have no
/// independent version header. Public parsing does not call this staged API.
pub(super) fn parse(
p: &mut Cursor<'_, '_>,
version: u32,
lexical_owner: Option<Id>,
) -> Result<Declaration, Error> {
p.budget.active()?;
let kind_span = p.span();
let kind = declaration_kind(p.text())
.ok_or_else(|| error(kind_span, "Expected a short typed declaration word."))?;
if version != 8 {
return Err(error(kind_span, "Analysis declarations require source8."));
}
if lexical_owner.is_some() {
return Err(error(
kind_span,
"Analysis declarations and definitions must be roots.",
));
}
p.budget.entry(kind_span)?;
p.cursor += 1;
let alias = p.name()?;
let name_span = p.span();
let name = p.quoted()?;
let identity = p.identity()?;
p.open()?;
let opening = p.take("{")?;
let header = Header {
kind,
kind_span,
alias,
name,
name_span,
identity,
span: Span {
start: kind_span.start,
end: opening.start,
},
};
let mut fields = Fields::default();
let mut description = None;
let mut parent = None;
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
let mut parameter_names = BTreeSet::new();
let mut parameter_ids = BTreeSet::new();
while p.text() != "}" {
let keyword = p.span();
p.budget.entry(keyword)?;
if p.text() == "input" && kind == DeclarationKind::Calculation {
let parameter = p.parameter()?;
if !parameter_names.insert(parameter.name.name.clone()) {
return Err(error(parameter.name.span, "Duplicate parameter name."));
}
if let Some(id) = parameter.identity.value
&& !parameter_ids.insert(id)
{
return Err(error(
parameter.identity.literal.expect("explicit identity"),
"Duplicate parameter identity.",
));
}
parameters.push(parameter);
continue;
}
// Borrow the original spelling for duplicate detection; no field-name
// allocation and no map insertion can overwrite an earlier value.
let source = p.source;
let key = &source[keyword.start..keyword.end];
if !seen.insert(key) {
return Err(error(keyword, format!("Duplicate field '{key}'.")));
}
p.cursor += 1;
use DeclarationKind::*;
match (kind, key) {
(_, "description") => description = Some(p.field(keyword, |p| p.quoted())?),
(Property(_) | Check(_) | Case(_), "parent") => {
parent = Some(p.field(keyword, |p| {
p.take("none")?;
Ok(())
})?)
}
(QuantityKind, "dimension") => {
fields.dimension = Some(p.field(keyword, |p| p.dimension())?)
}
(QuantityKind, "algebra") => fields.algebra = Some(p.field(keyword, |p| p.algebra())?),
(Unit, "kind") => fields.kind = Some(p.field(keyword, |p| p.at_reference())?),
(Unit, "scale") => fields.scale = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "offset") => fields.offset = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "symbol") => fields.symbol = Some(p.field(keyword, |p| p.quoted())?),
(Calculation, "result") => fields.result = Some(p.field(keyword, |p| p.value_type())?),
(Calculation, "body") => fields.body = Some(p.field(keyword, syntax::binding)?),
(Property(_), "subject") => {
fields.subject = Some(p.field(keyword, |p| p.at_reference())?)
}
(Property(_), "type") => {
fields.value_type = Some(p.field(keyword, |p| p.value_type())?)
}
(Property(_), "base") => fields.base = Some(p.field(keyword, syntax::binding)?),
(Property(_), "display_unit") => {
fields.display_unit = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_) | Case(_), "subject") => {
fields.optional_subject = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "documented_constraint") => {
fields.documented_constraint = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "predicate") => fields.predicate = Some(p.field(keyword, syntax::binding)?),
(Case(_), "base") => {
fields.baseline = Some(p.field(keyword, |p| {
p.take("ModelBaseline")?;
Ok(())
})?)
}
(Case(_), "overrides") => fields.overrides = Some(p.field(keyword, |p| p.overrides())?),
_ => {
return Err(error(
keyword,
format!("Unknown field '{key}' for this typed declaration."),
));
}
}
}
let closing = p.take("}")?;
p.close();
let body = fields.finish(kind, parameters, closing)?;
Ok(Declaration {
header,
span: Span {
start: kind_span.start,
end: closing.end,
},
body_span: Span {
start: opening.end,
end: closing.start,
},
description,
parent,
body,
})
}
impl Cursor<'_, '_> {
fn field<T>(
&mut self,
keyword: Span,
parse: impl FnOnce(&mut Self) -> Result<T, Error>,
) -> Result<Field<T>, Error> {
let start = self.span().start;
let value = parse(self)?;
let end = self.last_end();
let terminator = self.take(";")?;
Ok(Field {
span: Span {
start: keyword.start,
end: terminator.end,
},
keyword,
value_span: Span { start, end },
value,
})
}
fn identity(&mut self) -> Result<IdentitySyntax, Error> {
if self.text() != "id" {
return Ok(IdentitySyntax {
value: None,
keyword: None,
literal: None,
insertion: Some(self.span().start),
});
}
let keyword = self.take("id")?;
let literal = self.span();
let spelling = self.quoted()?;
if spelling.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|at| spelling.as_bytes()[at] == b'-')
{
return Err(error(
literal,
"Expected UUID in 8-4-4-4-12 hexadecimal form.",
));
}
let id = Id::parse_str(&spelling).map_err(|_| error(literal, "Invalid UUID identity."))?;
Ok(IdentitySyntax {
value: Some(id),
keyword: Some(keyword),
literal: Some(literal),
insertion: None,
})
}
fn parameter(&mut self) -> Result<ParameterSyntax, Error> {
let start = self.take("input")?.start;
let name = self.name()?;
let identity = self.identity()?;
self.take(":")?;
let value_type = self.value_type()?;
let end = self.take(";")?.end;
Ok(ParameterSyntax {
span: Span { start, end },
name,
identity,
value_type,
})
}
fn at_reference(&mut self) -> Result<Reference, Error> {
self.take("@")?;
self.reference()
}
fn optional_reference(&mut self) -> Result<Option<Reference>, Error> {
if self.text() == "none" {
self.cursor += 1;
Ok(None)
} else {
self.at_reference().map(Some)
}
}
fn exact_value(&mut self) -> Result<ExactSyntax, Error> {
let (value, literal) = self.exact_literal()?;
Ok(ExactSyntax { value, literal })
}
fn value_type(&mut self) -> Result<TypeSyntax, Error> {
let start = self.span().start;
self.budget.entry(self.span())?;
let list = self.text() == "List";
if list {
self.open()?;
self.cursor += 1;
self.take("(")?;
}
let scalar = self.scalar_type()?;
if list {
self.take(")")?;
self.close();
}
Ok(TypeSyntax {
span: Span {
start,
end: self.last_end(),
},
scalar,
list,
})
}
fn scalar_type(&mut self) -> Result<ScalarSyntax, Error> {
let start = self.span().start;
let kind = match self.text() {
"Boolean" => {
self.cursor += 1;
ScalarKind::Boolean
}
"Number" => {
self.cursor += 1;
ScalarKind::Number
}
"Quantity" => {
self.open()?;
self.cursor += 1;
self.take("(")?;
let reference = self.at_reference()?;
self.take(")")?;
self.close();
ScalarKind::Quantity(reference)
}
_ => {
return Err(error(
self.span(),
"Expected Boolean, Number or Quantity(@kind); nested List is not a scalar type.",
));
}
};
Ok(ScalarSyntax {
span: Span {
start,
end: self.last_end(),
},
kind,
})
}
fn dimension(&mut self) -> Result<DimensionSyntax, Error> {
self.open()?;
self.take("{")?;
let mut exponents = [0; 7];
let mut seen = [false; 7];
let mut entries = Vec::new();
while self.text() != "}" {
let axis = self.span();
self.budget.entry(axis)?;
let index = match self.text() {
"length" => 0,
"mass" => 1,
"time" => 2,
"electric_current" => 3,
"temperature" => 4,
"amount" => 5,
"luminous_intensity" => 6,
_ => return Err(error(axis, "Unknown dimension axis.")),
};
if seen[index] {
return Err(error(axis, "Duplicate dimension axis."));
}
seen[index] = true;
self.cursor += 1;
let start = self.span().start;
let negative = self.text() == "-";
if negative {
self.cursor += 1;
}
let digits = self.integer()?;
let value = self.source[digits.start..digits.end]
.parse::<i16>()
.map_err(|_| error(digits, "Dimension exponent exceeds -32..32."))?;
if value > 32 {
return Err(error(digits, "Dimension exponent exceeds -32..32."));
}
exponents[index] = if negative { -value } else { value };
let exponent = Span {
start,
end: digits.end,
};
let end = self.take(";")?.end;
entries.push(DimensionEntry {
axis,
exponent,
span: Span {
start: axis.start,
end,
},
});
}
self.take("}")?;
self.close();
Ok(DimensionSyntax { exponents, entries })
}
fn algebra(&mut self) -> Result<AlgebraSyntax, Error> {
if self.text() == "Linear" {
self.cursor += 1;
return Ok(AlgebraSyntax::Linear);
}
self.take("Affine")?;
self.open()?;
self.take("(")?;
let difference = self.at_reference()?;
self.take(")")?;
self.close();
Ok(AlgebraSyntax::Affine(difference))
}
fn overrides(&mut self) -> Result<Vec<OverrideSyntax>, Error> {
self.open()?;
self.take("{")?;
let mut entries = Vec::new();
let mut names = BTreeSet::new();
while self.text() != "}" {
let start = self.span().start;
self.budget.entry(self.span())?;
let target = self.at_reference()?;
if !names.insert(target.name.clone()) {
return Err(error(target.span, "Duplicate override target."));
}
self.take("=")?;
let binding_start = self.span().start;
let binding = syntax::binding(self)?;
let binding_span = Span {
start: binding_start,
end: self.last_end(),
};
let end = self.take(";")?.end;
entries.push(OverrideSyntax {
span: Span { start, end },
target,
binding,
binding_span,
});
}
self.take("}")?;
self.close();
Ok(entries)
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,613 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::budget::Limits;
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn declaration(source: &str) -> Declaration {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut budget);
let result = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
result
}
fn failure(source: &str) -> Error {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut budget), 8, None).unwrap_err()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn required_declarations_have_independent_typed_body_and_header_oracles() {
let source = "quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" { dimension { time 1; length -2; }; algebra Affine(@difference); description \"雪\"; }";
let actual = declaration(source);
assert_eq!(actual.header.kind, DeclarationKind::QuantityKind);
assert_eq!(actual.header.alias.name, "duration");
assert_eq!(actual.header.name, "Duration");
assert_eq!(
actual.header.identity.value,
Some(Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap())
);
assert_eq!(actual.description.as_ref().unwrap().value, "雪");
assert!(actual.parent.is_none());
let Body::QuantityKind { dimension, algebra } = actual.body else {
panic!("quantity kind")
};
assert_eq!(dimension.value.exponents, [-2, 0, 1, 0, 0, 0, 0]);
assert_eq!(text(source, dimension.keyword), "dimension");
assert_eq!(text(source, dimension.value_span), "{ time 1; length -2; }");
assert_eq!(text(source, dimension.value.entries[1].exponent), "-2");
let AlgebraSyntax::Affine(difference) = algebra.value else {
panic!("affine")
};
assert_eq!(difference.name, "difference");
let Body::Unit { kind, scale, offset, symbol } = declaration("unit millisecond \"Millisecond\" { kind @duration; scale ratio (1, // divisor\n 1000); offset 0; symbol \"ms\"; }").body else { panic!("unit") };
assert_eq!(kind.value.name, "duration");
assert_eq!(scale.value.value.canonical_literal(), "ratio(1, 1000)");
assert_eq!(offset.value.value.canonical_literal(), "0");
assert_eq!(symbol.value, "ms");
let Body::Calculation { parameters, result, body } = declaration("calculation combine \"Combine durations\" { input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration); input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration); result Quantity(@duration); body $a + $b; }").body else { panic!("calculation") };
assert_eq!(
parameters
.iter()
.map(|p| p.name.name.as_str())
.collect::<Vec<_>>(),
["a", "b"]
);
assert!(!result.value.list);
assert!(
matches!(result.value.scalar.kind, ScalarKind::Quantity(ref r) if r.name == "duration")
);
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { op, left, right },
..
}) = body.value
else {
panic!("binary")
};
assert_eq!(op, BinaryOp::Add);
assert!(matches!(left.kind, ExpressionKind::Parameter(ref r) if r.name == "a"));
assert!(matches!(right.kind, ExpressionKind::Parameter(ref r) if r.name == "b"));
for (word, layer) in [("oa_property", Layer::Oa), ("sa_property", Layer::Sa)] {
let actual = declaration(&format!(
"{word} receive_time \"Receive time\" {{ subject @receive; type Quantity(@duration); base 750 [@millisecond]; display_unit @millisecond; parent none; }}"
));
assert_eq!(actual.header.kind, DeclarationKind::Property(layer));
assert!(actual.parent.is_some());
let Body::Property {
subject,
value_type,
base,
display_unit,
} = actual.body
else {
panic!("property")
};
assert_eq!(subject.value.name, "receive");
assert!(matches!(
value_type.value.scalar.kind,
ScalarKind::Quantity(_)
));
assert!(matches!(
base.value,
Binding::Expression(Expression {
kind: ExpressionKind::Quantity { .. },
..
})
));
assert_eq!(display_unit.unwrap().value.unwrap().name, "millisecond");
}
for word in ["oa_check", "sa_check"] {
let Body::Check { subject, documented_constraint, predicate } = declaration(&format!("{word} budget \"Response budget\" {{ subject none; documented_constraint @constraint; predicate @total_time <= @limit; }}")).body else { panic!("check") };
assert!(subject.unwrap().value.is_none());
assert_eq!(
documented_constraint.unwrap().value.unwrap().name,
"constraint"
);
assert!(matches!(
predicate.value,
Binding::Expression(Expression {
kind: ExpressionKind::Binary {
op: BinaryOp::LessOrEqual,
..
},
..
})
));
}
for word in ["oa_analysis", "sa_analysis"] {
let Body::Case { subject, base, overrides } = declaration(&format!("{word} degraded \"Degraded radio\" {{ subject @respond; base ModelBaseline; overrides {{ @receive_time = unbound; }}; }}")).body else { panic!("case") };
assert_eq!(subject.unwrap().value.unwrap().name, "respond");
assert!(base.value_span.end > base.value_span.start);
assert_eq!(overrides.value.len(), 1);
assert_eq!(overrides.value[0].target.name, "receive_time");
assert!(matches!(overrides.value[0].binding, Binding::Unbound(_)));
}
}
#[test]
fn empty_unbound_and_all_scalar_list_types_are_explicit_valid_syntax() {
for (spelling, list, kind) in [
("Boolean", false, 0),
("Number", false, 1),
("Quantity(@duration)", false, 2),
("List(Boolean)", true, 0),
("List(Number)", true, 1),
("List(Quantity(@duration))", true, 2),
] {
let Body::Calculation {
parameters,
result,
body,
} = declaration(&format!(
"calculation c \"C\" {{ result {spelling}; body unbound; }}"
))
.body
else {
panic!()
};
assert!(parameters.is_empty());
assert_eq!(result.value.list, list);
assert_eq!(
match result.value.scalar.kind {
ScalarKind::Boolean => 0,
ScalarKind::Number => 1,
ScalarKind::Quantity(_) => 2,
},
kind
);
assert!(matches!(body.value, Binding::Unbound(_)));
}
let Body::QuantityKind { dimension, algebra } =
declaration("quantity_kind q \"Q\" { dimension {}; algebra Linear; }").body
else {
panic!()
};
assert_eq!(dimension.value.exponents, [0; 7]);
assert!(dimension.value.entries.is_empty());
assert_eq!(algebra.value, AlgebraSyntax::Linear);
let Body::Case {
subject, overrides, ..
} = declaration("oa_analysis c \"C\" { base ModelBaseline; overrides {}; }").body
else {
panic!()
};
assert!(subject.is_none());
assert!(overrides.value.is_empty());
let Body::Check {
subject,
documented_constraint,
..
} = declaration("oa_check c \"C\" { predicate unbound; }").body
else {
panic!()
};
assert!(subject.is_none() && documented_constraint.is_none());
let Body::Property { display_unit, .. } =
declaration("oa_property p \"P\" { subject @s; type Number; base unbound; }").body
else {
panic!()
};
assert!(display_unit.is_none());
}
#[test]
fn missing_required_fields_and_unknown_or_duplicate_entries_reject_locally() {
let required_sets: &[(&str, &[&str])] = &[
(
"quantity_kind q \"Q\"",
&["dimension {};", "algebra Linear;"],
),
(
"unit u \"U\"",
&["kind @q;", "scale 1;", "offset 0;", "symbol \"u\";"],
),
("calculation c \"C\"", &["result Number;", "body unbound;"]),
(
"oa_property p \"P\"",
&["subject @s;", "type Number;", "base unbound;"],
),
("oa_check c \"C\"", &["predicate unbound;"]),
(
"oa_analysis a \"A\"",
&["base ModelBaseline;", "overrides {};"],
),
];
for (header, fields) in required_sets {
declaration(&format!("{header} {{ {} }}", fields.join(" ")));
for removed in 0..fields.len() {
let remaining = fields
.iter()
.enumerate()
.filter_map(|(i, field)| (i != removed).then_some(*field))
.collect::<Vec<_>>()
.join(" ");
let error = failure(&format!("{header} {{ {remaining} }}"));
assert!(
error.message.contains("Missing required field"),
"{error:?}"
);
let duplicate = format!("{header} {{ {} {} }}", fields.join(" "), fields[removed]);
assert!(failure(&duplicate).message.contains("Duplicate field"));
}
}
for source in [
"quantity_kind q \"Q\" { dimension { time 1; time 2; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { unknown 1; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { time 33; }; algebra Linear; }",
"unit u \"U\" { kind @q; scale 1 + 2; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale 1 [@u]; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale ratio(1, 0); offset 0; symbol \"u\"; }",
"calculation c \"C\" { result List(List(Number)); body unbound; }",
"calculation c \"C\" { input x : Number; input x : Number; result Number; body unbound; }",
"calculation c \"C\" { input x id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; input y id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; result Number; body unbound; }",
"oa_property p \"P\" { subject none; type Number; base unbound; }",
"oa_property p \"P\" { subject @s; type Number; base none; }",
"oa_property p \"P\" { subject @s; type Number; base unbound; parent @s; }",
"oa_analysis a \"A\" { base @another; overrides {}; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = clear; }; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }",
"oa_check c \"C\" { predicate @f(x: 1, x: 2); }",
"quantity_kind q \"Q\" { dimension {}; algebra Linear; parent none; }",
"oa_check c \"C\" { predicate unbound; invented none; }",
] {
failure(source);
}
let duplicate = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }";
let error = failure(duplicate);
assert_eq!(text(duplicate, error.span), "p");
assert!(error.span.start > duplicate.find("@p").unwrap() + 1);
}
#[test]
fn borrowed_cursor_preserves_full_file_spans_and_does_not_rescan_fields() {
let source = "// 雪\r\ncalculation c \"C\" { input a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a); }\r\nunit u \"U\" { kind @q; scale 1; offset 0; symbol \"u\"; }";
let mut shared = budget();
let tokens = lex::tokens(source, &mut shared).unwrap();
let lexical = shared.usage;
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let first = parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.text(), "unit");
let first_end = first.span.end;
let second = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
assert!(second.span.start > first_end);
assert_eq!(text(source, first.header.kind_span), "calculation");
assert_eq!(text(source, first.header.name_span), "\"C\"");
assert_eq!(text(source, second.header.alias.span), "u");
assert_eq!(
text(source, first.body_span).trim().strip_prefix("input"),
Some(
" a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a);"
)
);
let Body::Calculation {
parameters, body, ..
} = first.body
else {
panic!()
};
assert_eq!(text(source, parameters[0].span), "input a : Number;");
assert_eq!(text(source, parameters[0].value_type.span), "Number");
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { left, right, .. },
..
}) = body.value
else {
panic!()
};
let ExpressionKind::Call {
arguments: left, ..
} = left.kind
else {
panic!()
};
let ExpressionKind::Call {
arguments: right, ..
} = right.kind
else {
panic!()
};
let spans = [
left[0].parameter.span,
left[1].parameter.span,
right[0].parameter.span,
right[1].parameter.span,
];
assert_eq!(spans.map(|span| text(source, span)), ["a", "b", "a", "b"]);
assert!(spans.windows(2).all(|pair| pair[0].end < pair[1].start));
let literal = left[0].value.literal.as_ref().unwrap();
assert_eq!(text(source, literal.span), "ratio (1, // first\r\n 2)");
assert_eq!(
literal
.tokens
.iter()
.map(|span| text(source, *span))
.collect::<Vec<_>>(),
["ratio", "(", "1", ",", "2", ")"]
);
assert_eq!(shared.usage.bytes, source.len());
assert_eq!(shared.usage.bytes, lexical.bytes);
assert_eq!(shared.usage.tokens, lexical.tokens);
assert_eq!(shared.usage.references, 9); // two calls, four keys, two $a, unit kind.
}
#[test]
fn missing_id_metadata_is_stable_and_parameter_ids_are_owner_scoped() {
let source =
"calculation c \"C\" { input a // identity here\r\n : Number; result Number; body $a; }";
let first = declaration(source);
let second = declaration(source);
assert_eq!(first, second);
assert_eq!(
first.header.identity.insertion,
Some(source.find('{').unwrap())
);
let Body::Calculation { parameters, .. } = first.body else {
panic!()
};
let id = &parameters[0].identity;
assert!(id.value.is_none() && id.literal.is_none() && id.keyword.is_none());
assert_eq!(id.insertion, Some(source.find(':').unwrap()));
let explicit = "28ff40b0-1b8f-4735-8536-5a6ab21b56cb";
// Syntax does not add ParameterIds to a global identity registry, and an
// explicit parameter ID can equal its own calculation's global UUID.
for name in ["one", "two"] {
let source = format!(
"calculation {name} \"C\" id \"{explicit}\" {{ input a id \"{explicit}\" : Number; result Number; body $a; }}"
);
let actual = declaration(&source);
let Body::Calculation { parameters, .. } = actual.body else {
panic!()
};
assert_eq!(parameters[0].identity.value, actual.header.identity.value);
assert!(parameters[0].identity.insertion.is_none());
}
}
#[test]
fn inherited_version_root_and_short_word_contracts_do_not_enable_public8() {
let source = "oa_check c \"C\" { predicate unbound; }";
for version in [1, 2, 3, 4, 5, 6, 7, 9] {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let error = parse(
&mut Cursor::new(source, &tokens, &mut budget),
version,
None,
)
.unwrap_err();
assert_eq!(text(source, error.span), "oa_check");
assert!(error.message.contains("source8"));
}
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
assert!(
parse(
&mut Cursor::new(source, &tokens, &mut budget),
8,
Some(Id::nil())
)
.unwrap_err()
.message
.contains("roots")
);
for word in [
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
assert!(declaration_kind(word).is_none());
}
for alias in [
"unit",
"calculation",
"quantity_kind",
"oa_property",
"if",
"and",
"List",
] {
let old = format!("arcadia 7; project {alias} \"P\" {{}}");
assert!(crate::language::parse(&old).is_ok(), "{old}");
}
let contextual =
declaration("calculation unit \"C\" { input and : Number; result Number; body $and; }");
assert_eq!(contextual.header.alias.name, "unit");
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for version in 3..=7 {
assert!(
crate::language::parse(&format!(
"arcadia {version}; project p \"P\" {{ {source} }}"
))
.is_err()
);
}
}
#[test]
fn declaration_budgets_share_literals_types_references_and_failure_state() {
let source = "unit u \"U\" { kind @q; scale ratio(1, 3); offset ratio(2, 7); symbol \"u\"; }";
let mut measured = budget();
let tokens = lex::tokens(source, &mut measured).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut measured), 8, None).unwrap();
let numeric = measured.numeric_units();
assert!(numeric > 0);
// All declaration, field and type entries share the same preallocation
// ceiling. Measure a deterministic valid counterpart before reducing it.
for (maximum, succeeds) in [
(measured.usage.entries, true),
(measured.usage.entries - 1, false),
] {
let mut profile = limits();
profile.entries = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
if !succeeds {
assert!(result.unwrap_err().message.contains("entries"));
}
}
for (maximum, succeeds) in [(numeric, true), (numeric - 1, false)] {
let mut profile = limits();
profile.numeric_units = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
}
let first =
"calculation c \"C\" { input x : Quantity(@q); result Quantity(@q); body unbound; }";
let second = "oa_property p \"P\" { subject @s; type Quantity(@q); base 1 [@u]; }";
let mut profile = limits();
profile.references = 4;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(first, &mut shared).unwrap();
parse(&mut Cursor::new(first, &tokens, &mut shared), 8, None).unwrap();
assert_eq!(shared.usage.references, 2);
let tokens = lex::tokens(second, &mut shared).unwrap();
let error = parse(&mut Cursor::new(second, &tokens, &mut shared), 8, None).unwrap_err();
assert_eq!(text(second, error.span), "u");
assert_eq!(shared.usage.references, 4);
assert_eq!(shared.active().unwrap_err(), error);
let numeric_after_failure = shared.numeric_units();
assert!(lex::tokens("1", &mut shared).is_err());
assert_eq!(shared.numeric_units(), numeric_after_failure);
let unbound_tokens = lex::tokens("unbound", &mut budget()).unwrap();
assert_eq!(
syntax::binding(&mut Cursor::new("unbound", &unbound_tokens, &mut shared)).unwrap_err(),
error
);
assert!(
declaration("oa_check fresh \"Fresh\" { predicate true; }")
.parent
.is_none()
);
}
#[test]
fn typed_frame_budget_and_truncated_declaration_cleanup_have_nearby_successes() {
let source = "calculation c \"C\" { result List(Quantity(@q)); body unbound; }";
for (maximum, succeeds) in [(3, true), (2, false)] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
assert_eq!(
parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None).is_ok(),
succeeds
);
}
let source = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = if(true, ratio(1, 2), @f(a: 1)); }; }";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens
.iter()
.filter(|token| matches!(text(source, token.span), "{" | "(" | "," | ":" | "="))
{
let prefix = &source[..token.span.end];
let mut shared = budget();
let tokens = lex::tokens(prefix, &mut shared).unwrap();
assert!(parse(&mut Cursor::new(prefix, &tokens, &mut shared), 8, None).is_err());
declaration("oa_check fresh \"Fresh\" { predicate true; }");
}
}
#[test]
fn typed_brace_frames_have_exact_zero_one_two_boundaries() {
let mut profile = limits();
profile.frames = 0;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens("unbound", &mut shared).unwrap();
let mut cursor = Cursor::new("unbound", &tokens, &mut shared);
assert!(matches!(
syntax::binding(&mut cursor).unwrap(),
Binding::Unbound(_)
));
assert_eq!(cursor.frames, 0);
assert_eq!(shared.usage.deepest_frame, 0);
for (source, needed) in [
("calculation c \"C\" { result Number; body unbound; }", 1),
("quantity_kind q \"Q\" { dimension {}; algebra Linear; }", 2),
(
"oa_analysis a \"A\" { base ModelBaseline; overrides {}; }",
2,
),
] {
for maximum in 0..=2 {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let result = parse(&mut cursor, 8, None);
assert_eq!(
result.is_ok(),
maximum >= needed,
"{source}, limit {maximum}"
);
if let Err(error) = result {
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
assert_eq!(
error.span.start,
if maximum == 0 {
source.find('{').unwrap()
} else {
source.rfind('{').unwrap()
}
);
} else {
assert_eq!(cursor.frames, 0);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, needed);
}
}
}
}
#[test]
fn declaration_frames_restore_the_callers_inherited_context() {
let source = "calculation a \"A\" { result Number; body unbound; } calculation b \"B\" { result Number; body unbound; }";
for maximum in [1, 2] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
// Future project/include integration owns this outer context. The
// declaration consumer must add to it, then restore it after closing.
cursor.frames = 1;
let first = parse(&mut cursor, 8, None);
if maximum == 1 {
let error = first.unwrap_err();
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
} else {
assert!(first.is_ok());
assert_eq!(cursor.frames, 1);
parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.frames, 1);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, 2);
}
}
}
@@ -0,0 +1,709 @@
//! Temporary typed source witnesses. No arena index becomes a model identity.
//! The staged entry point parses each supplied typed file once. A future bundle
//! parser must retain the same opaque parse/lower provenance. Occurrence
//! completeness trusts that lowerer; the private builder does not prove the
//! presence of every syntax reference in a caller-fabricated Lowered value.
use super::cursor::Cursor;
use super::lower::{self, Child, Field, Lowered, Role, Target, Unit};
use super::{Budget, Error, Span, declarations, error, lex};
use crate::{Id, Project, analysis as a};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
/// Persistent structural path: cloning shares ancestors, and equality/order
/// compare the complete edge sequence, never allocation or arena identities.
/// Depth is checked against the inherited frame limit before allocation.
/// Comparison and Debug walk leaf-to-root without allocating or recursing;
/// this private total order need not be root-first lexicographic order.
#[derive(Clone, Default)]
pub(super) struct Path(Option<Arc<Step>>);
struct Step {
parent: Path,
edge: Child,
}
impl PartialEq for Path {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for Path {}
impl PartialOrd for Path {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Path {
fn cmp(&self, other: &Self) -> Ordering {
let (mut left, mut right) = (self, other);
loop {
match (left.0.as_deref(), right.0.as_deref()) {
(Some(a), Some(b)) => {
let order = a.edge.cmp(&b.edge);
if order != Ordering::Equal {
return order;
}
(left, right) = (&a.parent, &b.parent);
}
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
}
}
}
}
impl fmt::Debug for Path {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
// Debug intentionally shows the same leaf-to-root order as comparison.
let mut list = formatter.debug_list();
let mut path = self;
while let Some(step) = &path.0 {
list.entry(&step.edge);
path = &step.parent;
}
list.finish()
}
}
impl Drop for Path {
fn drop(&mut self) {
let mut next = self.0.take();
while let Some(link) = next {
// Shared ancestors remain owned elsewhere. The final owner peels
// unique links here, taking the parent before Step is dropped.
let Some(mut step) = Arc::into_inner(link) else {
break;
};
next = step.parent.0.take();
}
}
}
impl Path {
pub fn edges(&self) -> Vec<Child> {
let mut result = Vec::new();
let mut path = self;
while let Some(step) = &path.0 {
result.push(step.edge);
path = &step.parent;
}
result.reverse();
result
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct ExpressionKey {
pub owner: Id,
pub field: Field,
pub path: Path,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum TokenRole {
Declaration,
Use(Role),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct OccurrenceKey {
pub owner: Id,
/// None is a global declaration header, not a binding field.
pub field: Option<Field>,
/// None is outside an expression; Some(empty) is its root call site.
pub path: Option<Path>,
pub role: TokenRole,
pub target: Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Location {
pub file: usize,
pub span: Span,
}
#[derive(Debug)]
pub(super) struct Index<'s> {
// Private provenance: callers can inspect locations, not supply replacements
// for the source witness, keys, catalog or occurrence spans.
sources: &'s BTreeMap<usize, String>,
names: BTreeMap<Target, String>,
occurrences: BTreeMap<OccurrenceKey, Location>,
expressions: BTreeMap<ExpressionKey, Location>,
}
impl<'s> Index<'s> {
pub fn sources(&self) -> &BTreeMap<usize, String> {
self.sources
}
pub fn names(&self) -> &BTreeMap<Target, String> {
&self.names
}
pub fn occurrences(&self) -> &BTreeMap<OccurrenceKey, Location> {
&self.occurrences
}
pub fn expressions(&self) -> &BTreeMap<ExpressionKey, Location> {
&self.expressions
}
/// Typed-only staged adapter. `ordinary` has already been parsed by its
/// owner. Missing typed IDs fail in lowering; this never invents UUIDs.
/// The caller merges the returned additions and admits the complete model.
pub fn parse(
ordinary: &Project,
sources: &'s BTreeMap<usize, String>,
budget: &mut Budget,
) -> Result<(Self, Lowered), lower::Failure> {
let mut units = Vec::new();
let mut witnesses = BTreeMap::new();
for (&file, source) in sources {
let failure = |error| lower::Failure {
file: Some(file),
error,
};
budget
.lowering_entries(units.len() + 1, Span::default())
.map_err(failure)?;
let tokens = lex::tokens(source, budget).map_err(failure)?;
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).map_err(failure)?);
}
units.push(Unit {
file,
declarations: parsed,
});
witnesses.insert(file, tokens);
}
let lowered = lower::lower(ordinary, &units, budget)?;
// A build failure after our own parse/lower is a phase/invariant error,
// not an invented file assignment for an ambiguous numeric span.
let index = Self::build(ordinary, sources, &units, &witnesses, &lowered, budget)
.map_err(|error| lower::Failure { file: None, error })?;
Ok((index, lowered))
}
// Same-parse/lower internal seam only. Checks below reject damaged supplied
// sites but occurrence completeness is an invariant of our own lowerer.
// Do not expose a constructor accepting independently fabricated Lowered.
fn build(
ordinary: &Project,
sources: &'s BTreeMap<usize, String>,
units: &[Unit],
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
lowered: &Lowered,
budget: &mut Budget,
) -> Result<Self, Error> {
budget.active()?;
budget.lowering_entries(lowered.expressions.len(), Span::default())?;
budget.lowering_occurrences(lowered.occurrences.len(), Span::default())?;
let mut names = BTreeMap::new();
let mut name_bytes = 0usize;
let mut insert_name = |target, name: &str| -> Result<(), Error> {
budget.lowering_entries(names.len() + 1, Span::default())?;
name_bytes = name_bytes
.checked_add(name.len())
.ok_or_else(|| error(Span::default(), "Index name size overflow."))?;
budget.index_bytes(name_bytes, Span::default())?;
if names.insert(target, name.to_owned()).is_some() {
return Err(error(
Span::default(),
"Duplicate qualified catalog identity.",
));
}
Ok(())
};
// Lowering has already bounded and validated the complete ordinary
// identity catalog before this existing convenience visitor allocates.
for (id, alias) in ordinary.identities() {
insert_name(Target::Global(id), alias)?;
}
for definition in ordinary
.definitions
.values()
.chain(lowered.definitions.values())
{
if lowered.definitions.contains_key(&definition.id) {
insert_name(Target::Global(definition.id), &definition.alias)?;
}
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
for parameter in &calculation.parameters {
insert_name(
Target::Parameter {
calculation: definition.id,
parameter: parameter.id,
},
&parameter.name,
)?;
}
}
}
for element in lowered.elements.values() {
insert_name(Target::Global(element.id), &element.alias)?;
}
let mut index = Self {
sources,
names,
occurrences: BTreeMap::new(),
expressions: BTreeMap::new(),
};
let mut owners = BTreeMap::new();
let mut physical = BTreeSet::new();
for unit in units {
for declaration in &unit.declarations {
let owner = declaration.header.identity.value.ok_or_else(|| {
error(
declaration.span,
"Missing ID requires source stabilization.",
)
})?;
let location = Location {
file: unit.file,
span: declaration.span,
};
checked_source(sources, location)?;
budget.lowering_entries(owners.len() + 1, declaration.span)?;
if owners.insert(owner, location).is_some() {
return Err(error(declaration.span, "Duplicate source owner."));
}
let key = OccurrenceKey {
owner,
field: None,
path: None,
role: TokenRole::Declaration,
target: Target::Global(owner),
};
index.insert(
key,
Location {
file: unit.file,
span: declaration.header.alias.span,
},
witnesses,
&mut physical,
budget,
)?;
}
}
// Parent-before-child is part of the lowering contract. One forward
// pass rejects forward, cyclic, cross-file/owner/field and duplicate
// paths. Each node allocates one shared edge, not a copied path vector.
let mut paths: Vec<(ExpressionKey, usize)> = Vec::new();
for (position, site) in lowered.expressions.iter().enumerate() {
if !matches!(
site.field,
Field::Body | Field::Base | Field::Predicate | Field::Override(_)
) {
return Err(error(
site.span,
"Expression occurs outside a binding field.",
));
}
let location = Location {
file: site.file,
span: site.span,
};
checked_source(sources, location)?;
let owner = owners
.get(&site.owner)
.ok_or_else(|| error(site.span, "Unknown expression owner."))?;
contained(*owner, location)?;
let (path, depth) = if let Some((parent, edge)) = site.parent {
if parent >= position {
return Err(error(
site.span,
"Expression parent must precede its child.",
));
}
let ancestor = &lowered.expressions[parent];
if ancestor.owner != site.owner
|| ancestor.field != site.field
|| ancestor.file != site.file
{
return Err(error(
site.span,
"Expression parent crosses its owner, field or file.",
));
}
contained(
Location {
file: ancestor.file,
span: ancestor.span,
},
location,
)?;
if ancestor.span == site.span {
return Err(error(
site.span,
"Child expression must be a proper source subtree.",
));
}
let depth = paths[parent].1 + 1;
budget.frame(depth, site.span)?;
(
Path(Some(Arc::new(Step {
parent: paths[parent].0.path.clone(),
edge,
}))),
depth,
)
} else {
budget.frame(1, site.span)?;
(Path::default(), 1)
};
let key = ExpressionKey {
owner: site.owner,
field: site.field,
path,
};
if index.expressions.insert(key.clone(), location).is_some() {
return Err(error(site.span, "Duplicate qualified expression path."));
}
paths.push((key, depth));
}
for occurrence in &lowered.occurrences {
let location = Location {
file: occurrence.file,
span: occurrence.span,
};
let owner = owners
.get(&occurrence.owner)
.ok_or_else(|| error(occurrence.span, "Unknown occurrence owner."))?;
contained(*owner, location)?;
let path = match occurrence.expression {
Some(site) => {
let expression = lowered
.expressions
.get(site)
.ok_or_else(|| error(occurrence.span, "Unknown occurrence expression."))?;
if expression.owner != occurrence.owner
|| expression.field != occurrence.field
|| expression.file != occurrence.file
{
return Err(error(
occurrence.span,
"Occurrence crosses its expression owner, field or file.",
));
}
contained(
Location {
file: expression.file,
span: expression.span,
},
location,
)?;
Some(paths[site].0.path.clone())
}
None => None,
};
match (occurrence.role, occurrence.target) {
(
Role::ParameterDeclaration,
Target::Parameter {
calculation,
parameter,
},
) if calculation == occurrence.owner
&& occurrence.field == Field::Input(parameter)
&& path.is_none() => {}
(Role::ParameterUse, Target::Parameter { calculation, .. })
if calculation == occurrence.owner
&& occurrence.field == Field::Body
&& path.is_some() => {}
(Role::ArgumentLabel, Target::Parameter { .. }) if path.is_some() => {}
(Role::OverrideTarget, Target::Global(id))
if occurrence.field == Field::Override(id) && path.is_none() => {}
(Role::CallTarget, Target::Global(_)) if path.is_some() => {}
(Role::Reference, Target::Global(_)) => {}
_ => return Err(error(occurrence.span, "Invalid qualified occurrence role.")),
}
let key = OccurrenceKey {
owner: occurrence.owner,
field: Some(occurrence.field),
path,
role: TokenRole::Use(occurrence.role),
target: occurrence.target,
};
index.insert(key, location, witnesses, &mut physical, budget)?;
}
// Argument labels belong to this precise call site and its callee, not
// merely to a parameter UUID/name present somewhere in the catalog.
let mut calls = BTreeMap::new();
for (key, at) in &index.occurrences {
if key.role == TokenRole::Use(Role::CallTarget) {
let site = (key.owner, key.field, key.path.clone());
if calls.insert(site, key.target).is_some() {
return Err(error(at.span, "Multiple callees at one expression site."));
}
}
}
for (key, at) in &index.occurrences {
if let (TokenRole::Use(Role::ArgumentLabel), Target::Parameter { calculation, .. }) =
(key.role, key.target)
&& calls.get(&(key.owner, key.field, key.path.clone()))
!= Some(&Target::Global(calculation))
{
return Err(error(
at.span,
"Argument label crosses its called calculation.",
));
}
}
for (position, site) in lowered.expressions.iter().enumerate() {
if let Some((parent, Child::Argument(parameter))) = site.parent {
let key = &paths[parent].0;
let Some(Target::Global(calculation)) =
calls.get(&(key.owner, Some(key.field), Some(key.path.clone())))
else {
return Err(error(site.span, "Argument child has no owning call."));
};
let label = OccurrenceKey {
owner: site.owner,
field: Some(site.field),
path: Some(key.path.clone()),
role: TokenRole::Use(Role::ArgumentLabel),
target: Target::Parameter {
calculation: *calculation,
parameter,
},
};
if !index.occurrences.contains_key(&label) || paths[position].0.path == key.path {
return Err(error(site.span, "Argument child has no qualified label."));
}
}
}
index.check_syntax_edges(units, lowered, budget)?;
Ok(index)
}
/// Check structural edges against the already parsed syntax, not just the
/// arena's internally consistent claims. This is one iterative tree walk,
/// with indexed span lookup; it does not lex or copy source/path strings.
fn check_syntax_edges<'a>(
&self,
units: &'a [Unit],
lowered: &Lowered,
budget: &Budget,
) -> Result<(), Error> {
let mut globals = BTreeMap::new();
let mut parameters = BTreeMap::new();
for (target, name) in &self.names {
match target {
Target::Global(id) => {
globals.insert(name.as_str(), *id);
}
Target::Parameter {
calculation,
parameter,
} => {
parameters.insert((*calculation, name.as_str()), *parameter);
}
}
}
let mut sites = BTreeMap::new();
for (position, site) in lowered.expressions.iter().enumerate() {
if sites
.insert(
(
site.file,
site.owner,
site.field,
site.span.start,
site.span.end,
),
position,
)
.is_some()
{
return Err(error(site.span, "Duplicate expression source ownership."));
}
}
let mut work = Vec::new();
let mut root = |file, owner, field, binding: &'a super::Binding| -> Result<(), Error> {
if let super::Binding::Expression(expression) = binding {
budget.lowering_entries(work.len() + 1, expression.span)?;
work.push((file, owner, field, expression, None));
}
Ok(())
};
for unit in units {
for declaration in &unit.declarations {
let owner = declaration.header.identity.value.expect("validated owner");
match &declaration.body {
declarations::Body::Calculation { body, .. } => {
root(unit.file, owner, Field::Body, &body.value)?
}
declarations::Body::Property { base, .. } => {
root(unit.file, owner, Field::Base, &base.value)?
}
declarations::Body::Check { predicate, .. } => {
root(unit.file, owner, Field::Predicate, &predicate.value)?
}
declarations::Body::Case { overrides, .. } => {
for entry in &overrides.value {
let target =
*globals.get(entry.target.name.as_str()).ok_or_else(|| {
error(entry.target.span, "Unknown syntax override target.")
})?;
root(unit.file, owner, Field::Override(target), &entry.binding)?;
}
}
_ => {}
}
}
}
let mut visited = 0usize;
while let Some((file, owner, field, expression, parent)) = work.pop() {
budget.lowering_entries(visited + 1, expression.span)?;
visited += 1;
let site = *sites
.get(&(
file,
owner,
field,
expression.span.start,
expression.span.end,
))
.ok_or_else(|| {
error(
expression.span,
"Parsed expression is absent from the source arena.",
)
})?;
if lowered.expressions[site].parent != parent {
return Err(error(
expression.span,
"Arena parent/child edge differs from original syntax.",
));
}
let mut child = |value: &'a super::Expression, edge| -> Result<(), Error> {
budget.lowering_entries(work.len() + 1, value.span)?;
work.push((file, owner, field, value, Some((site, edge))));
Ok(())
};
use super::ExpressionKind as E;
match &expression.kind {
E::List(items) => {
for (index, item) in items.iter().enumerate() {
child(item, Child::Item(index))?;
}
}
E::Unary { operand, .. } => child(operand, Child::Operand)?,
E::Binary { left, right, .. } => {
child(left, Child::Left)?;
child(right, Child::Right)?;
}
E::If {
condition,
when_true,
when_false,
} => {
child(condition, Child::Condition)?;
child(when_true, Child::WhenTrue)?;
child(when_false, Child::WhenFalse)?;
}
E::Call {
definition,
arguments,
} => {
let calculation = *globals
.get(definition.name.as_str())
.ok_or_else(|| error(definition.span, "Unknown syntax call target."))?;
for argument in arguments {
let parameter = *parameters
.get(&(calculation, argument.parameter.name.as_str()))
.ok_or_else(|| {
error(argument.parameter.span, "Unknown syntax call parameter.")
})?;
child(&argument.value, Child::Argument(parameter))?;
}
}
E::Sum { values, initial } => {
child(values, Child::Values)?;
child(initial, Child::Initial)?;
}
E::Power { base, .. } => child(base, Child::Base)?,
E::AsKind { value, .. } => child(value, Child::Value)?,
_ => {}
}
}
if visited != lowered.expressions.len() {
return Err(error(
Span::default(),
"Arena contains expressions absent from parsed syntax.",
));
}
Ok(())
}
fn insert(
&mut self,
key: OccurrenceKey,
location: Location,
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
physical: &mut BTreeSet<(usize, usize, usize)>,
budget: &Budget,
) -> Result<(), Error> {
budget.lowering_occurrences(self.occurrences.len() + 1, location.span)?;
let text = checked_source(self.sources, location)?;
if self.names.get(&key.target).is_none_or(|name| name != text) {
return Err(error(
location.span,
"Token differs from its qualified target name.",
));
}
let tokens = witnesses
.get(&location.file)
.ok_or_else(|| error(location.span, "Missing lexical witness."))?;
let token = tokens
.binary_search_by_key(&location.span.start, |token| token.span.start)
.ok()
.map(|at| tokens[at]);
if token.is_none_or(|token| token.span != location.span || token.class != lex::Class::Word)
{
return Err(error(
location.span,
"Occurrence is not an original whole name token.",
));
}
if self.occurrences.contains_key(&key)
|| !physical.insert((location.file, location.span.start, location.span.end))
{
return Err(error(
location.span,
"Duplicate qualified occurrence or token ownership.",
));
}
self.occurrences.insert(key, location);
Ok(())
}
}
pub(super) fn checked_source(
sources: &BTreeMap<usize, String>,
location: Location,
) -> Result<&str, Error> {
if location.span.start >= location.span.end {
return Err(error(location.span, "Empty or reversed source span."));
}
sources
.get(&location.file)
.and_then(|source| source.get(location.span.start..location.span.end))
.ok_or_else(|| {
error(
location.span,
"Source span is outside its original UTF-8 file.",
)
})
}
fn contained(parent: Location, child: Location) -> Result<(), Error> {
if parent.file != child.file
|| child.span.start < parent.span.start
|| child.span.end > parent.span.end
{
return Err(error(
child.span,
"Source span crosses its containing syntax.",
));
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,630 @@
use super::*;
use crate::language::ParseLimits;
const OA: &str = include_str!("../lower/fixtures/typed-oa.arcadia");
const ORDINARY: &str = include_str!("../lower/fixtures/ordinary-oa.arcadia");
fn budget() -> Budget {
Budget::new(super::super::budget::Limits::source8(
ParseLimits::UNLIMITED,
))
.unwrap()
}
fn ordinary() -> Project {
crate::language::parse(ORDINARY).unwrap()
}
fn sources() -> BTreeMap<usize, String> {
let split = OA.find("calculation combine").unwrap();
BTreeMap::from([
(7, OA[..split].replace('\n', "\r\n")),
(19, OA[split..].to_owned()),
])
}
fn raw(
sources: &BTreeMap<usize, String>,
budget: &mut Budget,
) -> (Vec<Unit>, BTreeMap<usize, Vec<lex::Token>>, Lowered) {
let mut units = Vec::new();
let mut witnesses = BTreeMap::new();
for (&file, source) in sources {
let tokens = lex::tokens(source, budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, budget);
let mut declarations = Vec::new();
while cursor.cursor < tokens.len() {
declarations.push(declarations::parse(&mut cursor, 8, None).unwrap());
}
units.push(Unit { file, declarations });
witnesses.insert(file, tokens);
}
let lowered = lower::lower(&ordinary(), &units, budget).unwrap();
(units, witnesses, lowered)
}
#[test]
fn staged_index_preserves_complete_oa_sa_models_and_original_file_spans() {
for (layer, ordinary_source, typed) in [
(crate::Layer::Oa, ORDINARY, OA),
(
crate::Layer::Sa,
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
include_str!("../lower/fixtures/typed-sa.arcadia"),
),
] {
let sources = BTreeMap::from([(37, typed.replace('\n', "\r\n"))]);
let skeleton = crate::language::parse(ordinary_source).unwrap();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
let mut actual = skeleton;
actual.definitions.extend(lowered.definitions);
actual.elements.extend(lowered.elements);
assert_eq!(actual, super::super::model_oracle::project(layer));
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(work.usage.bytes, sources[&37].len(), "one lexical pass");
for (key, at) in index.occurrences() {
assert_eq!(at.file, 37);
assert_eq!(
&sources[&37][at.span.start..at.span.end],
index.names[&key.target]
);
}
assert!(!index.expressions().is_empty());
}
}
#[test]
fn repeated_reordered_calls_have_stable_full_paths_and_four_labels() {
let expression = "@combine(b: @dispatch_time, a: @receive_time) // first 雪\n + @combine(a: @receive_time, b: @dispatch_time)";
let original = OA.replace("@combine(a: @receive_time, b: @dispatch_time)", expression);
let reordered = original.replace(
"@combine(b: @dispatch_time, a: @receive_time)",
"@combine(a: @receive_time, b: @dispatch_time)",
);
let first = BTreeMap::from([(7, original)]);
let second = BTreeMap::from([(7, reordered)]);
let (left, _) = Index::parse(&ordinary(), &first, &mut budget()).unwrap();
let (right, _) = Index::parse(&ordinary(), &second, &mut budget()).unwrap();
assert_eq!(
left.occurrences.keys().collect::<Vec<_>>(),
right.occurrences.keys().collect::<Vec<_>>()
);
assert_eq!(
left.expressions.keys().collect::<Vec<_>>(),
right.expressions.keys().collect::<Vec<_>>()
);
let labels: Vec<_> = left
.occurrences
.iter()
.filter(|(key, _)| key.role == TokenRole::Use(Role::ArgumentLabel))
.collect();
assert_eq!(labels.len(), 4);
let paths: BTreeSet<_> = labels
.iter()
.map(|(key, _)| key.path.as_ref().unwrap().edges())
.collect();
assert_eq!(
paths,
BTreeSet::from([vec![Child::Left], vec![Child::Right]])
);
let spans: BTreeSet<_> = labels
.iter()
.map(|(_, at)| (at.span.start, at.span.end))
.collect();
assert_eq!(spans.len(), 4);
}
#[test]
fn index_checks_every_expression_edge_and_keeps_override_fields_distinct() {
let source = OA
.replace("body $a + $b;", "body if(if(true, 1 < 2, false), sum([$a, abs(-$b)], 0 [@second]), as_kind(@duration, power(1 [@second], 2) / 1 [@second]));")
.replace("@receive_time = 1250 [@millisecond];", "@receive_time = @combine(a: 1 [@second], b: 2 [@second]); @dispatch_time = @combine(b: 2 [@second], a: 1 [@second]);");
let sources = BTreeMap::from([(17, source)]);
let (index, lowered) = Index::parse(&ordinary(), &sources, &mut budget()).unwrap();
let mut project = ordinary();
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
let edges: BTreeSet<_> = index
.expressions
.keys()
.flat_map(|key| key.path.edges())
.collect();
for edge in [
Child::Item(0),
Child::Item(1),
Child::Operand,
Child::Left,
Child::Right,
Child::Condition,
Child::WhenTrue,
Child::WhenFalse,
Child::Values,
Child::Initial,
Child::Base,
Child::Value,
] {
assert!(edges.contains(&edge), "missing {edge:?}");
}
assert!(edges.iter().any(|edge| matches!(edge, Child::Argument(_))));
let labels: Vec<_> = index
.occurrences
.keys()
.filter(|key| {
key.role == TokenRole::Use(Role::ArgumentLabel)
&& matches!(key.field, Some(Field::Override(_)))
})
.collect();
assert_eq!(labels.len(), 4);
assert_eq!(
labels
.iter()
.map(|key| key.field)
.collect::<BTreeSet<_>>()
.len(),
2
);
assert!(
labels
.iter()
.all(|key| key.path.as_ref().unwrap().edges().is_empty())
);
}
#[test]
fn arena_rejects_forward_cycles_and_cross_owner_field_file_parents() {
for damage in 0..6 {
let sources = sources();
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let child = lowered
.expressions
.iter()
.position(|site| site.parent.is_some())
.unwrap();
let parent = lowered.expressions[child].parent.unwrap().0;
match damage {
0 => lowered.expressions[child].parent = Some((child, Child::Left)),
1 => lowered.expressions[parent].parent = Some((child, Child::Right)),
2 => lowered.expressions[child].owner = ordinary().id,
3 => lowered.expressions[child].field = Field::Predicate,
4 => lowered.expressions[child].file = 12345,
_ => lowered.expressions[child].parent = Some((parent, Child::Operand)),
}
assert!(
Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work
)
.is_err(),
"damage {damage}"
);
}
}
#[test]
fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() {
for damage in 0..4 {
let mut sources = sources();
// The other callee is fully valid and reuses both parameter identities
// and spellings. Catalog/token validation must not explain rejection.
sources.insert(
31,
r#"calculation other "Other" id "00000000-0000-0000-0000-00000000cafe" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}"#
.into(),
);
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let valid = Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work,
)
.unwrap();
let mut expected_mismatch_span = None;
match damage {
0 => {
let site = &lowered.expressions[0];
lowered.expressions.push(lower::ExpressionSite {
file: site.file,
owner: site.owner,
field: site.field,
parent: site.parent,
span: site.span,
});
}
1 => {
let row = &lowered.occurrences[0];
lowered.occurrences.push(lower::Occurrence {
file: row.file,
owner: row.owner,
field: row.field,
expression: row.expression,
role: row.role,
target: row.target,
span: row.span,
});
}
2 => {
let label = lowered
.occurrences
.iter_mut()
.find(|row| row.role == Role::ArgumentLabel)
.unwrap();
let original = label.target;
let Target::Parameter {
calculation,
parameter,
} = &mut label.target
else {
panic!("argument label must target a parameter");
};
let other = Target::Parameter {
calculation: Id::from_u128(0xcafe),
parameter: *parameter,
};
assert_eq!(valid.names[&original], valid.names[&other]);
*calculation = Id::from_u128(0xcafe);
expected_mismatch_span = Some(label.span);
}
_ => {
let row = lowered
.occurrences
.iter_mut()
.find(|row| row.role == Role::OverrideTarget)
.unwrap();
row.field = Field::Override(ordinary().id);
}
}
let failure = Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work,
)
.unwrap_err();
if let Some(span) = expected_mismatch_span {
assert_eq!(
failure.message,
"Argument label crosses its called calculation."
);
assert_eq!(failure.span, span);
}
}
}
#[test]
fn index_rejects_comment_partial_and_non_utf8_occurrence_spans() {
for damage in 0..3 {
let mut sources = sources();
let with_comment =
sources[&7].replace("subject @receive;", "subject @receive; // duration 雪\r\n");
sources.insert(7, with_comment);
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let occurrence = lowered
.occurrences
.iter_mut()
.find(|row| row.file == 7 && &sources[&7][row.span.start..row.span.end] == "duration")
.unwrap();
match damage {
0 => {
let start = sources[&7].find("// duration").unwrap() + 3;
occurrence.span = Span {
start,
end: start + 8,
};
}
1 => occurrence.span.end -= 1,
_ => {
let start = sources[&7].find('雪').unwrap() + 1;
occurrence.span = Span {
start,
end: start + 1,
};
}
}
assert!(
Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work
)
.is_err()
);
}
}
#[test]
fn index_has_shared_budget_and_never_stabilizes_missing_ids() {
let source = "calculation f \"F\" { result Number; body unbound; }";
let files = BTreeMap::from([(91, source.into())]);
let missing = Index::parse(&ordinary(), &files, &mut budget()).unwrap_err();
assert_eq!(missing.file, Some(91));
assert!(missing.error.message.contains("stabilization"));
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = 1;
let mut work = Budget::new(limits).unwrap();
assert!(Index::parse(&ordinary(), &sources(), &mut work).is_err());
assert!(work.active().is_err());
}
fn on_small_stack(work: impl FnOnce() + Send + 'static) {
// Match the established core language_nesting_tests worker-stack request.
// This is a requested platform stack size, not a measured peak-byte claim.
std::thread::Builder::new()
.name("analysis-index-128k".into())
.stack_size(128 * 1024)
.spawn(work)
.unwrap()
.join()
.unwrap();
}
fn chain(first: Child, edges: usize) -> Path {
let mut path = Path::default();
for position in 0..edges {
path = Path(Some(Arc::new(Step {
parent: path,
edge: if position == 0 { first } else { Child::Operand },
})));
}
path
}
#[test]
fn maximum_index_paths_compare_lookup_debug_and_drop_on_128k_stack() {
on_small_stack(|| {
let frames = super::super::budget::Limits::source8(ParseLimits::UNLIMITED).frames;
assert_eq!(frames, 256);
let edges = frames - 1; // root has frame depth one
let mut work = budget();
work.frame(frames, Span::default()).unwrap();
let left = chain(Child::Left, edges);
let equal = chain(Child::Left, edges); // independent allocations
let right = chain(Child::Right, edges);
assert!(!Arc::ptr_eq(
left.0.as_ref().unwrap(),
equal.0.as_ref().unwrap()
));
assert_eq!(left, equal);
assert_eq!(left.partial_cmp(&equal), Some(Ordering::Equal));
// The difference is at the root-most edge: leaf-first walks every link.
assert_eq!(left.cmp(&right), Child::Left.cmp(&Child::Right));
assert_eq!(right.cmp(&left), left.cmp(&right).reverse());
assert!(Path::default() < left);
assert!(chain(Child::Operand, edges - 1) < chain(Child::Operand, edges));
let shared = left.clone();
assert!(Arc::ptr_eq(
left.0.as_ref().unwrap(),
shared.0.as_ref().unwrap()
));
let leaf = Arc::downgrade(left.0.as_ref().unwrap());
let key = |path| ExpressionKey {
owner: Id::from_u128(1),
field: Field::Body,
path,
};
let mut expressions = BTreeMap::new();
assert_eq!(expressions.insert(key(left.clone()), 1), None);
assert_eq!(expressions.insert(key(equal.clone()), 2), Some(1));
assert_eq!(expressions.insert(key(right.clone()), 3), None);
assert_eq!(expressions.get(&key(equal.clone())), Some(&2));
assert_eq!(expressions.get(&key(chain(Child::Right, edges))), Some(&3));
assert_eq!(expressions.len(), 2);
let occurrence = |path| OccurrenceKey {
owner: Id::from_u128(1),
field: Some(Field::Body),
path: Some(path),
role: TokenRole::Use(Role::ParameterUse),
target: Target::Parameter {
calculation: Id::from_u128(1),
parameter: a::ParameterId(Id::from_u128(2)),
},
};
let mut occurrences = BTreeSet::new();
assert!(occurrences.insert(occurrence(left.clone())));
assert!(!occurrences.insert(occurrence(equal.clone())));
assert!(occurrences.contains(&occurrence(chain(Child::Left, edges))));
let debug = format!("{left:?}");
assert_eq!(debug.matches("Operand").count(), edges - 1);
assert!(debug.ends_with("Left]"));
assert!(!format!("{expressions:?} {occurrences:?}").is_empty());
drop(expressions);
drop(occurrences);
drop(shared);
drop(left);
assert!(leaf.upgrade().is_none(), "maps/clones release their chain");
drop(equal);
drop(right);
// Dropping a child stops at a shared ancestor, which remains usable.
let parent = chain(Child::Left, edges - 1);
let ancestor = Arc::downgrade(parent.0.as_ref().unwrap());
let child = Path(Some(Arc::new(Step {
parent: parent.clone(),
edge: Child::Right,
})));
drop(child);
assert_eq!(parent.edges().len(), edges - 1);
drop(parent);
assert!(ancestor.upgrade().is_none());
// No other strong owners: final destruction must peel the full chain.
let unique = chain(Child::Left, edges);
let final_leaf = Arc::downgrade(unique.0.as_ref().unwrap());
drop(unique);
assert!(final_leaf.upgrade().is_none());
});
}
#[test]
fn deep_parse_index_plan_and_failed_build_cleanup_on_128k_stack() {
on_small_stack(|| {
use super::super::update::Plan;
let deep = format!("{}$a{}", "abs(".repeat(127), ")".repeat(127));
let source = format!(
r#"calculation f "Deep 雪" id "00000000-0000-0000-0000-000000000081" {{
input a id "00000000-0000-0000-0000-000000000083" : Number;
result Number; body {deep};
}}
calculation caller "Caller" id "00000000-0000-0000-0000-000000000082" {{
result Number; body @f(a: 1);
}}
"#
);
let sources = BTreeMap::from([(47, source.clone())]);
let skeleton = ordinary();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
assert_eq!(work.usage.bytes, source.len());
assert_eq!(index.expressions.len(), 130); // 128 unary/leaf + call/argument
assert_eq!(
index.expressions.keys().map(|k| k.path.edges().len()).max(),
Some(127)
);
let renames = BTreeMap::from([
(Target::Global(Id::from_u128(0x81)), "renamed_deep".into()),
(
Target::Parameter {
calculation: Id::from_u128(0x81),
parameter: a::ParameterId(Id::from_u128(0x83)),
},
"renamed_arg".into(),
),
]);
let usage = work.usage;
let plan = Plan::renames(&index, &renames, &work).unwrap();
assert_eq!(plan.edit_count(), 5); // declaration/call + input/use/label
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(work.usage, usage);
// Independent complete original-token oracle; no index-derived offsets.
let expected_source = source
.replace("calculation f ", "calculation renamed_deep ")
.replace("input a id", "input renamed_arg id")
.replace("$a", "$renamed_arg")
.replace("@f(a: 1)", "@renamed_deep(renamed_arg: 1)");
assert_eq!(output, BTreeMap::from([(47, expected_source)]));
let (renamed_index, renamed) = Index::parse(&skeleton, &output, &mut budget()).unwrap();
assert_eq!(
index.occurrences.keys().collect::<Vec<_>>(),
renamed_index.occurrences.keys().collect::<Vec<_>>()
);
// Compare complete independently constructed models, including deepest
// parameter identity and the call's parameter-keyed argument map.
for (lowered, renamed) in [(lowered, false), (renamed, true)] {
let mut expected = skeleton.clone();
let parameter = a::ParameterId(Id::from_u128(0x83));
let mut expression = a::Expr::Parameter(parameter);
for _ in 0..127 {
expression = a::Expr::Unary {
op: a::UnaryOp::Absolute,
operand: Box::new(expression),
};
}
for (id, alias, name, parameters, expression) in [
(
0x81,
if renamed { "renamed_deep" } else { "f" },
"Deep 雪",
vec![a::Parameter {
id: parameter,
name: if renamed { "renamed_arg" } else { "a" }.into(),
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
}],
expression,
),
(
0x82,
"caller",
"Caller",
Vec::new(),
a::Expr::Call {
definition: a::DefinitionRef::Local(Id::from_u128(0x81)),
arguments: BTreeMap::from([(
parameter,
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(1)),
)]),
},
),
] {
let id = Id::from_u128(id);
expected.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters,
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(expression),
}),
},
);
}
let mut actual = skeleton.clone();
actual.definitions.extend(lowered.definitions);
actual.elements.extend(lowered.elements);
assert_eq!(actual, expected);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
drop(actual);
drop(expected);
}
drop(plan);
drop(renamed_index);
drop(index);
// Failure after the deepest keys have been allocated must clean up on
// the same stack; no modified Lowered escapes this private test seam.
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let deepest = lowered
.expressions
.iter()
.find(|site| &sources[&site.file][site.span.start..site.span.end] == "$a")
.unwrap();
lowered.expressions.push(lower::ExpressionSite {
file: deepest.file,
owner: deepest.owner,
field: deepest.field,
parent: deepest.parent,
span: deepest.span,
});
let failure =
Index::build(&skeleton, &sources, &units, &witnesses, &lowered, &mut work).unwrap_err();
assert_eq!(failure.message, "Duplicate qualified expression path.");
lowered.expressions.pop();
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
limits.frames = 127;
let mut bounded = Budget::new(limits).unwrap();
let failure = Index::build(
&skeleton,
&sources,
&units,
&witnesses,
&lowered,
&mut bounded,
)
.unwrap_err();
assert!(failure.message.contains("syntax frames"));
assert!(bounded.active().is_err());
drop(lowered);
drop(witnesses);
drop(units);
let rejected = BTreeMap::from([(47, source.replace(&deep, &format!("abs({deep})")))]);
let failure = Index::parse(&skeleton, &rejected, &mut budget()).unwrap_err();
assert!(failure.error.message.contains("logical depth"));
});
}
@@ -0,0 +1,52 @@
//! Source8 significant-token adapter over the shared raw scanner.
use super::super::raw_tokens::{Class as RawClass, Scanner};
use super::{Budget, Error, Span, error};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Class {
Word,
Number,
String,
Punctuation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Token {
pub class: Class,
pub span: Span,
}
pub fn tokens(source: &str, budget: &mut Budget) -> Result<Vec<Token>, Error> {
budget.bytes(
source.len(),
Span {
start: 0,
end: source.len(),
},
)?;
let mut scanner = Scanner::new(source);
let mut result = Vec::new();
let mut offset = 0;
while offset < source.len() {
// Charge trivia too, before the scanner advances or a token is stored.
budget.token(Span {
start: offset,
end: offset,
})?;
let token = scanner.next().expect("source remains");
offset = token.span.end;
let class = match token.class {
RawClass::Word => Class::Word,
RawClass::Number => Class::Number,
RawClass::String => Class::String,
RawClass::Punctuation => Class::Punctuation,
RawClass::Whitespace | RawClass::Comment => continue,
RawClass::Error => return Err(error(token.span, "Invalid source token.")),
};
result.push(Token {
class,
span: token.span,
});
}
Ok(result)
}
@@ -0,0 +1,939 @@
//! Staged typed resolution. The caller owns parsing, ID stabilization and the
//! single complete-Project semantic admission after merging these additions.
use super::declarations::{self as d, Declaration, DeclarationKind};
use super::*;
use crate::analysis as a;
use crate::analysis_numeric::Dimension;
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Caller-assigned file key; spans are original offsets within that file.
pub(super) struct Unit {
pub file: usize,
pub declarations: Vec<Declaration>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Failure {
/// None denotes an invalid ordinary skeleton, before typed resolution.
pub file: Option<usize>,
pub error: Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Category {
Project,
Element(Kind, Layer),
Relation,
Diagram,
Node,
Artifact,
Trace,
QuantityKind,
Unit,
Calculation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Target {
Global(Id),
Parameter {
calculation: Id,
parameter: a::ParameterId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Field {
Algebra,
Kind,
Input(a::ParameterId),
Result,
Body,
Subject,
Type,
Base,
DisplayUnit,
DocumentedConstraint,
Predicate,
Override(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Role {
Reference,
ParameterDeclaration,
ParameterUse,
CallTarget,
ArgumentLabel,
OverrideTarget,
}
/// Stable structural edges, independent of alias spelling and argument order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Child {
Item(usize),
Operand,
Left,
Right,
Condition,
WhenTrue,
WhenFalse,
Argument(a::ParameterId),
Values,
Initial,
Base,
Value,
}
/// An arena index is only local to this lowering result. Follow parent edges
/// to obtain the owner/field/path identity for S3; never persist the index.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct ExpressionSite {
pub file: usize,
pub owner: Id,
pub field: Field,
pub parent: Option<(usize, Child)>,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Occurrence {
pub file: usize,
pub owner: Id,
pub field: Field,
pub expression: Option<usize>,
pub role: Role,
pub target: Target,
/// Alias/name token only, excluding @/$ and surrounding trivia.
pub span: Span,
}
#[derive(Debug)]
pub(super) struct Lowered {
pub definitions: BTreeMap<Id, a::Definition>,
pub elements: BTreeMap<Id, Element>,
pub occurrences: Vec<Occurrence>,
pub expressions: Vec<ExpressionSite>,
}
#[derive(Clone, Copy)]
struct Symbol {
id: Id,
category: Category,
}
#[derive(Default)]
struct Tables {
aliases: BTreeMap<String, Symbol>,
ids: BTreeSet<Id>,
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
entries: usize,
}
fn category(kind: DeclarationKind) -> Category {
match kind {
DeclarationKind::QuantityKind => Category::QuantityKind,
DeclarationKind::Unit => Category::Unit,
DeclarationKind::Calculation => Category::Calculation,
DeclarationKind::Property(layer) => Category::Element(Kind::EngineeringProperty, layer),
DeclarationKind::Check(layer) => Category::Element(Kind::AnalysisCheck, layer),
DeclarationKind::Case(layer) => Category::Element(Kind::AnalysisCase, layer),
}
}
fn identity(identity: &d::IdentitySyntax, span: Span) -> Result<Id, Error> {
identity.value.ok_or_else(|| {
error(
span,
"Missing ID requires source stabilization before lowering.",
)
})
}
impl Tables {
fn reserve(&mut self, span: Span, budget: &Budget) -> Result<(), Error> {
budget.lowering_entries(self.entries + 1, span)?;
self.entries += 1;
Ok(())
}
fn insert(
&mut self,
id: Id,
alias: &str,
category: Category,
span: Span,
budget: &Budget,
) -> Result<(), Error> {
// A phase allocation bound, not a second syntax charge. Ordinary
// records, typed headers and scoped parameters share this bound.
self.reserve(span, budget)?;
if self.ids.contains(&id) {
return Err(error(span, "Duplicate global identity."));
}
if self.aliases.contains_key(alias) {
return Err(error(span, format!("Duplicate global alias '{alias}'.")));
}
self.ids.insert(id);
self.aliases
.insert(alias.to_owned(), Symbol { id, category });
Ok(())
}
fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> {
let at = Span::default();
self.insert(project.id, &project.alias, Category::Project, at, budget)?;
for value in project.definitions.values() {
let category = match &value.body {
a::DefinitionBody::QuantityKind(_) => Category::QuantityKind,
a::DefinitionBody::Unit(_) => Category::Unit,
a::DefinitionBody::Calculation(_) => Category::Calculation,
};
self.insert(value.id, &value.alias, category, at, budget)?;
if let a::DefinitionBody::Calculation(calculation) = &value.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in &calculation.parameters {
self.reserve(at, budget)?;
if names.contains_key(&parameter.name) || !ids.insert(parameter.id) {
return Err(error(
at,
"Duplicate parameter name or identity in ordinary catalog.",
));
}
names.insert(parameter.name.clone(), parameter.id);
}
self.parameters.insert(value.id, names);
}
}
for value in project.elements.values() {
self.insert(
value.id,
&value.alias,
Category::Element(value.kind(), value.layer()),
at,
budget,
)?;
}
for value in project.relations.values() {
self.insert(value.id, &value.alias, Category::Relation, at, budget)?;
}
for value in project.diagrams.values() {
self.insert(value.id, &value.alias, Category::Diagram, at, budget)?;
for node in value.nodes.values() {
self.insert(node.id, &node.alias, Category::Node, at, budget)?;
}
}
for value in project.artifact_links.values() {
self.insert(value.id, &value.alias, Category::Artifact, at, budget)?;
}
for value in project.traces.values() {
self.insert(value.id, &value.alias, Category::Trace, at, budget)?;
}
Ok(())
}
fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> {
let header = &declaration.header;
let id = identity(&header.identity, header.alias.span)?;
self.insert(
id,
&header.alias.name,
category(header.kind),
header.alias.span,
budget,
)?;
if let d::Body::Calculation { parameters, .. } = &declaration.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in parameters {
self.reserve(parameter.span, budget)?;
let id = a::ParameterId(identity(&parameter.identity, parameter.name.span)?);
if names.contains_key(&parameter.name.name) || !ids.insert(id) {
return Err(error(
parameter.name.span,
"Duplicate parameter name or identity.",
));
}
names.insert(parameter.name.name.clone(), id);
}
self.parameters.insert(id, names);
}
Ok(())
}
}
/// Inputs must come from the same live Budget and stabilization pass. No scan,
/// exact conversion or reference charge occurs here. The ordinary skeleton is
/// not semantically admitted: its typed forward references may need additions.
pub(super) fn lower(
ordinary: &Project,
units: &[Unit],
budget: &Budget,
) -> Result<Lowered, Failure> {
budget
.active()
.map_err(|error| Failure { file: None, error })?;
let mut tables = Tables::default();
tables
.ordinary(ordinary, budget)
.map_err(|error| Failure { file: None, error })?;
let mut files = BTreeSet::new();
for unit in units {
budget
.lowering_entries(files.len() + 1, Span::default())
.map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
if !files.insert(unit.file) {
return Err(Failure {
file: Some(unit.file),
error: error(Span::default(), "Duplicate source file key."),
});
}
for declaration in &unit.declarations {
tables.typed(declaration, budget).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
for unit in units {
for declaration in &unit.declarations {
let owner = declaration
.header
.identity
.value
.expect("collected stable identity");
let mut resolver = Resolver {
tables: &tables,
output: &mut output,
budget,
file: unit.file,
owner,
};
resolver.declaration(declaration).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
Ok(output)
}
struct Resolver<'a> {
tables: &'a Tables,
output: &'a mut Lowered,
budget: &'a Budget,
file: usize,
owner: Id,
}
impl Resolver<'_> {
fn occurrence(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
target: Target,
) -> Result<(), Error> {
self.budget
.lowering_occurrences(self.output.occurrences.len() + 1, reference.span)?;
self.output.occurrences.push(Occurrence {
file: self.file,
owner: self.owner,
field,
expression,
role,
target,
span: reference.span,
});
Ok(())
}
fn global(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
expected: impl FnOnce(Category) -> bool,
) -> Result<Symbol, Error> {
let symbol = *self.tables.aliases.get(&reference.name).ok_or_else(|| {
error(
reference.span,
format!("Unknown global reference '@{}'.", reference.name),
)
})?;
if !expected(symbol.category) {
return Err(error(
reference.span,
format!(
"Wrong reference domain for '@{}': {:?}.",
reference.name, symbol.category
),
));
}
self.occurrence(
reference,
field,
expression,
role,
Target::Global(symbol.id),
)?;
Ok(symbol)
}
fn definition_ref(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
expected: Category,
) -> Result<a::DefinitionRef, Error> {
self.global(reference, field, expression, Role::Reference, |category| {
category == expected
})
.map(|symbol| a::DefinitionRef::Local(symbol.id))
}
fn value_type(
&mut self,
value: &d::TypeSyntax,
field: Field,
) -> Result<a::AnalysisType, Error> {
let scalar =
match &value.scalar.kind {
d::ScalarKind::Boolean => a::ScalarType::Boolean,
d::ScalarKind::Number => a::ScalarType::Number,
d::ScalarKind::Quantity(reference) => a::ScalarType::Quantity(
self.definition_ref(reference, field, None, Category::QuantityKind)?,
),
};
Ok(if value.list {
a::AnalysisType::List(scalar)
} else {
a::AnalysisType::Scalar(scalar)
})
}
fn subject(&mut self, reference: &Reference) -> Result<a::AnalysisSubject, Error> {
let symbol = self.global(
reference,
Field::Subject,
None,
Role::Reference,
|category| matches!(category, Category::Element(..) | Category::Relation),
)?;
Ok(if symbol.category == Category::Relation {
a::AnalysisSubject::Relation(symbol.id)
} else {
a::AnalysisSubject::Element(symbol.id)
})
}
fn optional_subject(
&mut self,
subject: &Option<d::Field<Option<Reference>>>,
) -> Result<Option<a::AnalysisSubject>, Error> {
subject
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| self.subject(reference))
.transpose()
}
fn declaration(&mut self, declaration: &Declaration) -> Result<(), Error> {
let header = &declaration.header;
let definition = match &declaration.body {
d::Body::QuantityKind { dimension, algebra } => {
Some(a::DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: Dimension::new(dimension.value.exponents)
.map_err(|cause| error(dimension.value_span, cause.to_string()))?,
algebra: match &algebra.value {
d::AlgebraSyntax::Linear => a::QuantityAlgebra::Linear,
d::AlgebraSyntax::Affine(reference) => a::QuantityAlgebra::Affine {
difference: self.definition_ref(
reference,
Field::Algebra,
None,
Category::QuantityKind,
)?,
},
},
}))
}
d::Body::Unit {
kind,
scale,
offset,
symbol,
} => Some(a::DefinitionBody::Unit(a::UnitDefinition {
kind: self.definition_ref(
&kind.value,
Field::Kind,
None,
Category::QuantityKind,
)?,
scale: scale.value.value.clone(),
offset: offset.value.value.clone(),
symbol: symbol.value.clone(),
})),
d::Body::Calculation {
parameters,
result,
body,
} => {
let mut lowered = Vec::new();
for parameter in parameters {
self.budget
.lowering_entries(lowered.len() + 1, parameter.span)?;
let id = self.tables.parameters[&self.owner][&parameter.name.name];
self.occurrence(
&parameter.name,
Field::Input(id),
None,
Role::ParameterDeclaration,
Target::Parameter {
calculation: self.owner,
parameter: id,
},
)?;
lowered.push(a::Parameter {
id,
name: parameter.name.name.clone(),
value_type: self.value_type(&parameter.value_type, Field::Input(id))?,
});
}
Some(a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: lowered,
result: self.value_type(&result.value, Field::Result)?,
body: self.binding(&body.value, Field::Body, Some(self.owner))?,
}))
}
_ => None,
};
let description = declaration
.description
.as_ref()
.map_or_else(String::new, |field| field.value.clone());
if let Some(body) = definition {
self.output.definitions.insert(
self.owner,
a::Definition {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
body,
},
);
return Ok(());
}
let (layer, body) = match (&declaration.body, header.kind) {
(
d::Body::Property {
subject,
value_type,
base,
display_unit,
},
DeclarationKind::Property(layer),
) => (
layer,
SharedBody::EngineeringProperty {
subject: self.subject(&subject.value)?,
value_type: self.value_type(&value_type.value, Field::Type)?,
base: self.binding(&base.value, Field::Base, None)?,
display_unit: display_unit
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.definition_ref(reference, Field::DisplayUnit, None, Category::Unit)
})
.transpose()?,
},
),
(
d::Body::Check {
subject,
documented_constraint,
predicate,
},
DeclarationKind::Check(layer),
) => (
layer,
SharedBody::AnalysisCheck {
subject: self.optional_subject(subject)?,
documented_constraint: documented_constraint
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.global(
reference,
Field::DocumentedConstraint,
None,
Role::Reference,
|category| {
matches!(category, Category::Element(Kind::Constraint, _))
},
)
.map(|symbol| symbol.id)
})
.transpose()?,
predicate: self.binding(&predicate.value, Field::Predicate, None)?,
},
),
(
d::Body::Case {
subject, overrides, ..
},
DeclarationKind::Case(layer),
) => {
let subject = self.optional_subject(subject)?;
let mut lowered = BTreeMap::new();
for entry in &overrides.value {
let property = self
.global(
&entry.target,
Field::Base,
None,
Role::OverrideTarget,
|category| {
matches!(category, Category::Element(Kind::EngineeringProperty, _))
},
)?
.id;
// Replace the placeholder field after resolution so the key
// and its binding share their stable override identity.
self.output
.occurrences
.last_mut()
.expect("override occurrence")
.field = Field::Override(property);
if lowered.contains_key(&property) {
return Err(error(
entry.target.span,
"Duplicate resolved override target.",
));
}
self.budget
.lowering_entries(lowered.len() + 1, entry.span)?;
lowered.insert(
property,
self.binding(&entry.binding, Field::Override(property), None)?,
);
}
(
layer,
SharedBody::AnalysisCase {
subject,
base: a::BaselineBase::ModelBaseline,
overrides: lowered,
},
)
}
_ => {
return Err(error(
header.kind_span,
"Typed declaration header/body mismatch.",
));
}
};
self.output.elements.insert(
self.owner,
Element {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
parent: None,
body: ElementBody::shared_in(layer, body),
},
);
Ok(())
}
fn binding(
&mut self,
binding: &Binding,
field: Field,
calculation: Option<Id>,
) -> Result<a::Binding, Error> {
match binding {
Binding::Unbound(_) => Ok(a::Binding::Unbound),
Binding::Expression(expression) => self
.expression(expression, field, calculation)
.map(a::Binding::Expression),
}
}
fn call(
&mut self,
definition: &Reference,
arguments: &[Argument],
field: Field,
site: usize,
) -> Result<Call, Error> {
let calculation = self
.global(
definition,
field,
Some(site),
Role::CallTarget,
|category| category == Category::Calculation,
)?
.id;
let tables = self.tables;
let signature = &tables.parameters[&calculation];
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
for argument in arguments {
self.budget
.lowering_entries(parameters.len() + 1, argument.parameter.span)?;
let parameter = *signature.get(&argument.parameter.name).ok_or_else(|| {
error(
argument.parameter.span,
"Unknown argument name in called calculation.",
)
})?;
if !seen.insert(parameter) {
return Err(error(
argument.parameter.span,
"Duplicate resolved argument parameter.",
));
}
self.occurrence(
&argument.parameter,
field,
Some(site),
Role::ArgumentLabel,
Target::Parameter {
calculation,
parameter,
},
)?;
parameters.push(parameter);
}
// Argument-set/type checking remains in existing semantic admission.
Ok(Call {
calculation,
parameters,
})
}
fn expression(
&mut self,
root: &Expression,
field: Field,
calculation: Option<Id>,
) -> Result<a::Expr, Error> {
enum Work<'a> {
Visit(&'a Expression, Option<(usize, Child)>),
Build(&'a Expression, usize, Option<Call>, usize),
}
let mut work = vec![Work::Visit(root, None)];
let mut values = Vec::new();
while let Some(task) = work.pop() {
match task {
Work::Visit(expression, parent) => {
self.budget
.lowering_entries(self.output.expressions.len() + 1, expression.span)?;
let site = self.output.expressions.len();
self.output.expressions.push(ExpressionSite {
file: self.file,
owner: self.owner,
field,
parent,
span: expression.span,
});
let call = if let ExpressionKind::Call {
definition,
arguments,
} = &expression.kind
{
Some(self.call(definition, arguments, field, site)?)
} else {
None
};
let child_count = match &expression.kind {
ExpressionKind::List(items) => items.len(),
ExpressionKind::Call { arguments, .. } => arguments.len(),
ExpressionKind::Unary { .. }
| ExpressionKind::Power { .. }
| ExpressionKind::AsKind { .. } => 1,
ExpressionKind::Binary { .. } | ExpressionKind::Sum { .. } => 2,
ExpressionKind::If { .. } => 3,
_ => 0,
};
self.budget
.lowering_entries(work.len() + child_count + 1, expression.span)?;
let mut children = Vec::with_capacity(child_count);
match &expression.kind {
ExpressionKind::List(items) => children.extend(
items
.iter()
.enumerate()
.map(|(index, child)| (child, Child::Item(index))),
),
ExpressionKind::Unary { operand, .. } => {
children.push((operand.as_ref(), Child::Operand))
}
ExpressionKind::Binary { left, right, .. } => children
.extend([(left.as_ref(), Child::Left), (right.as_ref(), Child::Right)]),
ExpressionKind::If {
condition,
when_true,
when_false,
} => children.extend([
(condition.as_ref(), Child::Condition),
(when_true.as_ref(), Child::WhenTrue),
(when_false.as_ref(), Child::WhenFalse),
]),
ExpressionKind::Call { arguments, .. } => children.extend(
arguments
.iter()
.zip(&call.as_ref().expect("call signature").parameters)
.map(|(argument, id)| (&argument.value, Child::Argument(*id))),
),
ExpressionKind::Sum { values, initial } => children.extend([
(values.as_ref(), Child::Values),
(initial.as_ref(), Child::Initial),
]),
ExpressionKind::Power { base, .. } => {
children.push((base.as_ref(), Child::Base))
}
ExpressionKind::AsKind { value, .. } => {
children.push((value.as_ref(), Child::Value))
}
_ => {}
}
work.push(Work::Build(expression, site, call, children.len()));
work.extend(
children
.into_iter()
.rev()
.map(|(child, edge)| Work::Visit(child, Some((site, edge)))),
);
}
Work::Build(expression, site, call, count) => {
let start = values.len() - count;
let mut children = values.split_off(start).into_iter();
let next = |children: &mut std::vec::IntoIter<a::Expr>| {
Box::new(children.next().expect("visited expression child"))
};
use ExpressionKind as E;
let value = match &expression.kind {
E::Boolean(value) => a::Expr::Boolean(*value),
E::Number(value) => a::Expr::Number(value.clone()),
E::Quantity { magnitude, unit } => a::Expr::Quantity {
magnitude: magnitude.clone(),
unit: self.definition_ref(unit, field, Some(site), Category::Unit)?,
},
E::Property(reference) => a::Expr::Property(
self.global(
reference,
field,
Some(site),
Role::Reference,
|category| {
matches!(
category,
Category::Element(Kind::EngineeringProperty, _)
)
},
)?
.id,
),
E::Parameter(reference) => {
let calculation = calculation.ok_or_else(|| {
error(
reference.span,
"Parameter use is only valid in its calculation body.",
)
})?;
let parameter = *self.tables.parameters[&calculation]
.get(&reference.name)
.ok_or_else(|| {
error(
reference.span,
"Unknown parameter in owning calculation.",
)
})?;
self.occurrence(
reference,
field,
Some(site),
Role::ParameterUse,
Target::Parameter {
calculation,
parameter,
},
)?;
a::Expr::Parameter(parameter)
}
E::List(_) => a::Expr::List(children.collect()),
E::Unary { op, .. } => a::Expr::Unary {
op: *op,
operand: next(&mut children),
},
E::Binary { op, .. } => a::Expr::Binary {
op: *op,
left: next(&mut children),
right: next(&mut children),
},
E::If { .. } => a::Expr::If {
condition: next(&mut children),
when_true: next(&mut children),
when_false: next(&mut children),
},
E::Call { .. } => {
let call = call.expect("resolved call");
a::Expr::Call {
definition: a::DefinitionRef::Local(call.calculation),
arguments: call.parameters.into_iter().zip(children).collect(),
}
}
E::Sum { .. } => a::Expr::Sum {
values: next(&mut children),
initial: next(&mut children),
},
E::Power { exponent, .. } => a::Expr::Power {
base: next(&mut children),
exponent: *exponent,
},
E::AsKind { kind, .. } => a::Expr::AsKind {
kind: self.definition_ref(
kind,
field,
Some(site),
Category::QuantityKind,
)?,
value: next(&mut children),
},
};
self.budget
.lowering_entries(values.len() + 1, expression.span)?;
values.push(value);
}
}
}
Ok(values.pop().expect("one lowered expression"))
}
}
struct Call {
calculation: Id,
parameters: Vec<a::ParameterId>,
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,7 @@
arcadia 7;
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -0,0 +1,7 @@
arcadia 7;
project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" {
sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {}
sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -0,0 +1,43 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -0,0 +1,43 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -0,0 +1,888 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::{budget::Limits, cursor::Cursor};
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");
const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia");
const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia");
fn budget() -> Budget {
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn id(text: &str) -> Id {
Id::parse_str(text).unwrap()
}
fn by_alias(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn ordinary(source: &str) -> Project {
crate::language::parse(source).unwrap()
}
fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit {
let tokens = lex::tokens(source, budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, budget);
let mut declarations = Vec::new();
while cursor.cursor < tokens.len() {
declarations.push(d::parse(&mut cursor, 8, None).unwrap());
}
Unit { file, declarations }
}
fn merge(mut ordinary: Project, lowered: Lowered) -> Project {
ordinary.definitions.extend(lowered.definitions);
ordinary.elements.extend(lowered.elements);
ordinary
}
fn admit(project: &Project) {
a::admit(project, a::AdmissionLimits::REFERENCE).unwrap();
}
fn failure(source: &str, ordinary: &Project) -> Failure {
let mut budget = budget();
let unit = unit(source, 7, &mut budget);
lower(ordinary, &[unit], &budget).unwrap_err()
}
#[test]
fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() {
for (layer, ordinary_source, typed_source) in
[(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)]
{
let expected = oracle::project(layer);
assert_eq!(expected.name, "Response budget");
assert_eq!(expected.identities().len(), 15); // two additional scoped IDs
for (alias, name) in oracle::NAMES {
let object = by_alias(&expected, alias);
let actual_name = expected
.definitions
.get(&object)
.map(|value| value.name.as_str())
.or_else(|| {
expected
.elements
.get(&object)
.map(|value| value.name.as_str())
})
.unwrap();
assert_eq!(actual_name, name);
assert_ne!(actual_name, alias);
}
let mut budget = budget();
let units = [unit(typed_source, 17, &mut budget)];
let usage = budget.usage;
let numeric = budget.numeric_units();
let skeleton = ordinary(ordinary_source);
let first = lower(&skeleton, &units, &budget).unwrap();
let second = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(first.occurrences, second.occurrences);
assert_eq!(first.expressions, second.expressions);
assert_eq!(budget.usage, usage);
assert_eq!(budget.numeric_units(), numeric);
assert_eq!(
first
.occurrences
.iter()
.filter(|occurrence| occurrence.role != Role::ParameterDeclaration)
.count(),
usage.references
);
let actual = merge(skeleton, first);
assert_eq!(actual, expected);
admit(&actual); // the complete model's one semantic admission
}
assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err());
}
#[test]
fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() {
let source = OA.replace('\n', "\r\n");
let split = source.find("calculation combine").unwrap();
let sources = [&source[..split], &source[split..]];
let mut budget = budget();
let mut units = [
unit(sources[0], 4, &mut budget),
unit(sources[1], 9, &mut budget),
];
let usage = budget.usage;
assert_eq!(usage.bytes, source.len());
let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
units.reverse();
// This second order uses the same bounded lowerer; output models must not
// depend on declaration/file traversal order. It is not an include parser.
let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(
merge(ordinary(ORDINARY_OA), reordered),
oracle::project(Layer::Oa)
);
for occurrence in &lowered.occurrences {
let source = if occurrence.file == 4 {
sources[0]
} else {
assert_eq!(occurrence.file, 9);
sources[1]
};
let text = &source[occurrence.span.start..occurrence.span.end];
assert!(crate::valid_alias(text));
assert!(!text.starts_with('@') && !text.starts_with('$'));
}
assert_eq!(budget.usage, usage);
assert_eq!(
merge(ordinary(ORDINARY_OA), lowered),
oracle::project(Layer::Oa)
);
}
fn number(value: i64) -> a::Expr {
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value))
}
fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr {
a::Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
#[test]
fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() {
let mut project = oracle::project(Layer::Oa);
let duration = by_alias(&project, "duration");
let second = by_alias(&project, "second");
let property = by_alias(&project, "receive_time");
let owner = by_alias(&project, "total_time");
let calculation = by_alias(&project, "combine");
let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let quantity =
a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration)));
let numeric = a::AnalysisType::Scalar(a::ScalarType::Number);
let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean);
let cases = [
("true", a::Expr::Boolean(true), boolean.clone()),
(
"ratio(3, 2)",
a::Expr::Number(
Exact::from_wire(
&crate::analysis_numeric::ExactWire {
numerator: "3".into(),
denominator: "2".into(),
},
&mut crate::analysis_numeric::WorkMeter::new(
crate::analysis_numeric::WorkLimits::REFERENCE,
),
)
.unwrap(),
),
numeric.clone(),
),
(
"2 [@second]",
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
quantity.clone(),
),
(
"@receive_time",
a::Expr::Property(property),
quantity.clone(),
),
("$a", a::Expr::Parameter(parameter), quantity.clone()),
(
"[1, 2]",
a::Expr::List(vec![number(1), number(2)]),
a::AnalysisType::List(a::ScalarType::Number),
),
(
"abs(-2)",
a::Expr::Unary {
op: a::UnaryOp::Absolute,
operand: Box::new(a::Expr::Unary {
op: a::UnaryOp::Negate,
operand: Box::new(number(2)),
}),
},
numeric.clone(),
),
(
"1 + 2",
binary(a::BinaryOp::Add, number(1), number(2)),
numeric.clone(),
),
(
"if(true, 1, 2)",
a::Expr::If {
condition: Box::new(a::Expr::Boolean(true)),
when_true: Box::new(number(1)),
when_false: Box::new(number(2)),
},
numeric.clone(),
),
(
"@combine(b: 2 [@second], a: 1 [@second])",
a::Expr::Call {
definition: a::DefinitionRef::Local(calculation),
arguments: BTreeMap::from([
(
parameter,
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
),
(
b,
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
),
]),
},
quantity.clone(),
),
(
"sum([1, 2], 0)",
a::Expr::Sum {
values: Box::new(a::Expr::List(vec![number(1), number(2)])),
initial: Box::new(number(0)),
},
numeric.clone(),
),
(
"power(2, -2)",
a::Expr::Power {
base: Box::new(number(2)),
exponent: -2,
},
numeric.clone(),
),
(
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
a::Expr::AsKind {
kind: a::DefinitionRef::Local(duration),
value: Box::new(binary(
a::BinaryOp::Divide,
a::Expr::Power {
base: Box::new(a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
}),
exponent: 2,
},
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
)),
},
quantity.clone(),
),
];
// Remove the original check's dependent quantity comparison: each case
// installs a different declared result type on total_time below.
project.elements.remove(&by_alias(&project, "budget"));
for (source, expected, result_type) in cases {
let mut budget = budget();
let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else {
panic!("expression")
};
let mut tables = Tables::default();
tables.ordinary(&project, &budget).unwrap();
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
let is_parameter = source == "$a";
let expression_owner = if is_parameter { calculation } else { owner };
let actual = Resolver {
tables: &tables,
output: &mut output,
budget: &budget,
file: 3,
owner: expression_owner,
}
.expression(
&syntax,
if is_parameter {
Field::Body
} else {
Field::Base
},
is_parameter.then_some(calculation),
)
.unwrap();
assert_eq!(actual, expected, "{source}");
assert_eq!(output.expressions[0].span, syntax.span);
assert!(output.expressions[0].parent.is_none());
let mut candidate = project.clone();
if is_parameter {
let a::DefinitionBody::Calculation(definition) =
&mut candidate.definitions.get_mut(&calculation).unwrap().body
else {
panic!("calculation")
};
definition.body = a::Binding::Expression(actual);
} else {
let SharedBody::EngineeringProperty {
value_type,
display_unit,
base,
..
} = candidate
.elements
.get_mut(&owner)
.unwrap()
.body
.shared_mut()
.unwrap()
else {
panic!("property")
};
*value_type = result_type;
*display_unit = None;
*base = a::Binding::Expression(actual);
}
admit(&candidate);
}
}
#[test]
fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() {
let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, // first\n b: @dispatch_time) + @combine(// second\n b: @dispatch_time, a: @receive_time);") + r#"
calculation other "Other" id "00000000-0000-0000-0000-000000000020" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number;
result Number; body $a;
}
quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; }
calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" {
input a id "00000000-0000-0000-0000-000000000022" : Number;
result Number; body @other(a: $a) + @other(a: 3);
}
"#;
let mut budget = budget();
let units = [unit(&source, 2, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
let labels: Vec<_> = output
.occurrences
.iter()
.filter(|value| value.owner == total && value.role == Role::ArgumentLabel)
.collect();
assert_eq!(labels.len(), 4);
assert_eq!(
labels
.iter()
.map(|value| value.span.start)
.collect::<BTreeSet<_>>()
.len(),
4
);
let sites: BTreeSet<_> = labels
.iter()
.map(|value| value.expression.unwrap())
.collect();
assert_eq!(sites.len(), 2);
let paths: Vec<_> = sites
.iter()
.map(|site| output.expressions[*site].parent.unwrap().1)
.collect();
assert_eq!(paths, [Child::Left, Child::Right]);
for label in labels {
assert_eq!(label.field, Field::Base);
assert!(
matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine)
);
assert!(matches!(
&source[label.span.start..label.span.end],
"a" | "b"
));
}
let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let scopes: BTreeSet<_> = output
.occurrences
.iter()
.filter_map(|occurrence| match occurrence.target {
Target::Parameter {
calculation,
parameter,
} if parameter == reused => Some(calculation),
_ => None,
})
.collect();
assert_eq!(
scopes,
BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")])
);
let caller = id("00000000-0000-0000-0000-000000000021");
let use_in_caller = output
.occurrences
.iter()
.find(|value| value.owner == caller && value.role == Role::ParameterUse)
.unwrap();
assert_eq!(
use_in_caller.target,
Target::Parameter {
calculation: caller,
parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022"))
}
);
for site in &output.expressions {
assert_eq!(site.file, 2);
assert!(source.get(site.span.start..site.span.end).is_some());
}
admit(&merge(ordinary(ORDINARY_OA), output));
}
#[test]
fn missing_ids_require_stabilization_without_uuid_or_budget_churn() {
for source in [
r#"calculation pending "Pending" { result Number; body unbound; }"#,
r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#,
] {
let mut budget = budget();
let units = [unit(source, 3, &mut budget)];
let usage = budget.usage;
let skeleton = ordinary(ORDINARY_OA);
let first = lower(&skeleton, &units, &budget).unwrap_err();
assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err());
assert!(first.error.message.contains("stabilization"));
assert_eq!(budget.usage, usage);
let declaration = &units[0].declarations[0];
if declaration.header.identity.value.is_none() {
assert!(declaration.header.identity.insertion.is_some());
} else {
let d::Body::Calculation { parameters, .. } = &declaration.body else {
panic!("calculation")
};
assert!(parameters[0].identity.value.is_none());
assert!(parameters[0].identity.insertion.is_some());
}
}
}
#[test]
fn duplicate_global_aliases_and_ids_fail_before_body_resolution() {
let ordinary = ordinary(ORDINARY_OA);
for (source, message) in [
(
OA.replace("quantity_kind duration", "quantity_kind respond"),
"Duplicate global alias",
),
(
OA.replace(
"188b911c-5f8b-431c-9eea-2d41f3953767",
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
),
"Duplicate global identity",
),
(format!("{OA}\n{OA}"), "Duplicate global"),
] {
let failed = failure(&source, &ordinary);
assert_eq!(failed.file, Some(7));
assert!(failed.error.message.contains(message), "{:?}", failed);
assert!(!failed.error.message.contains("Unknown"));
}
let mut budget = budget();
let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)];
let failed = lower(&ordinary, &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(5));
assert!(failed.error.message.contains("Duplicate global identity"));
}
#[test]
fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() {
let skeleton = ordinary(ORDINARY_OA);
for (old, new, token, message) in [
(
"subject @receive",
"subject @second",
"second",
"Wrong reference domain",
),
(
"Quantity(@duration)",
"Quantity(@second)",
"second",
"Wrong reference domain",
),
(
"display_unit @second",
"display_unit @duration",
"duration",
"Wrong reference domain",
),
(
"750 [@millisecond]",
"750 [@duration]",
"duration",
"Wrong reference domain",
),
(
"base 1 [@second]",
"base @respond",
"respond",
"Wrong reference domain",
),
(
"@combine(a:",
"@second(a:",
"second",
"Wrong reference domain",
),
(
"@combine(a:",
"@combine(unknown:",
"unknown",
"Unknown argument",
),
(
"body $a + $b",
"body $outside + $b",
"outside",
"Unknown parameter",
),
(
"base 1 [@second]",
"base $a",
"a",
"only valid in its calculation",
),
(
"subject @receive",
"subject @missing",
"missing",
"Unknown global",
),
(
"documented_constraint none",
"documented_constraint @respond",
"respond",
"Wrong reference domain",
),
(
"@receive_time = unbound",
"@second = unbound",
"second",
"Wrong reference domain",
),
] {
let source = OA.replace(old, new);
assert_ne!(source, OA, "mutation {old}");
let failed = failure(&source, &skeleton);
assert!(
failed.error.message.contains(message),
"{new}: {:?}",
failed
);
assert_eq!(
&source[failed.error.span.start..failed.error.span.end],
token
);
}
}
#[test]
fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() {
let mut skeleton = ordinary(ORDINARY_OA);
let respond = by_alias(&skeleton, "respond");
let receive = by_alias(&skeleton, "receive");
let relation = Id::from_u128(100);
skeleton.relations.insert(
relation,
crate::Relation {
id: relation,
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: crate::RelationBody::new(
crate::RelationKind::FunctionalExchange,
respond,
receive,
),
},
);
let diagram = skeleton.ensure_diagram(Layer::Oa);
skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into();
let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. });
skeleton
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.alias = "placement".into();
let artifact = Id::from_u128(101);
skeleton.artifact_links.insert(
artifact,
crate::ArtifactLink {
id: artifact,
alias: "document".into(),
name: "Document".into(),
description: String::new(),
owner: respond,
relation: crate::ArtifactRelation::References,
uri: "urn:test".into(),
revision: None,
fragment: None,
},
);
let trace = Id::from_u128(102);
skeleton.traces.insert(
trace,
crate::Trace {
id: trace,
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: respond,
sa: receive,
kind: crate::TraceKind::Manual,
baseline: None,
},
);
// Resolution classification is independent of whole-model admission; the
// synthetic trace is deliberately not asserted to be a valid OA/SA trace.
let budget = budget();
let mut tables = Tables::default();
tables.ordinary(&skeleton, &budget).unwrap();
for (alias, category) in [
("response_budget", Category::Project),
("link", Category::Relation),
("view", Category::Diagram),
("placement", Category::Node),
("document", Category::Artifact),
("mapping", Category::Trace),
] {
assert_eq!(tables.aliases[alias].category, category);
let failed = failure(
&OA.replace("base 1 [@second]", &format!("base @{alias}")),
&skeleton,
);
assert!(
failed.error.message.contains("Wrong reference domain"),
"{:?}",
failed
);
}
let source = OA.replace("subject @receive", "subject @link");
let mut budget = self::budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
let property = output
.elements
.values()
.find(|value| value.alias == "receive_time")
.unwrap();
assert!(
matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation)
);
}
#[test]
fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() {
for (old, new) in [
("base 750 [@millisecond]", "base true"),
(
"base 750 [@millisecond]",
"base as_kind(@duration, 1 [@second])",
),
("body $a + $b", "body @receive_time"),
("a: @receive_time, b: @dispatch_time", "a: @receive_time"),
("oa_property receive_time", "sa_property receive_time"),
("base 1 [@second]", "base @dispatch_time"),
] {
let source = OA.replace(old, new);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let project = merge(ordinary(ORDINARY_OA), output);
assert!(
a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(),
"{new}"
);
}
}
#[test]
fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() {
let ordinary = oracle::project(Layer::Oa);
for (maximum, passes) in [(0, false), (16, false), (17, true)] {
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = maximum;
let budget = Budget::new(limits).unwrap();
let result = lower(&ordinary, &[], &budget);
assert_eq!(result.is_ok(), passes, "entry bound {maximum}");
if let Err(failure) = result {
assert!(failure.error.message.contains("lowering entry limit"));
assert_eq!(failure.file, None);
}
assert_eq!(budget.usage, budget::Usage::default());
}
}
#[test]
fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() {
let source = r#"
unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" {
kind @point; scale 1; offset ratio(27315, 100); symbol "°C";
}
quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" {
dimension { temperature 1; }; algebra Affine(@interval);
}
quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" {
dimension { temperature 1; }; algebra Linear;
}
calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" {
input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval));
result List(Quantity(@interval)); body unbound; description "保留";
}
calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" {
result Boolean; body unbound;
}
oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" {
subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none;
}
oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" {
subject @respond; documented_constraint @requirement; predicate unbound;
}
oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" {
subject @respond; base ModelBaseline; overrides { @ambient = unbound; };
}
"#;
let mut skeleton = ordinary(ORDINARY_OA);
let requirement = Id::from_u128(70);
skeleton.elements.insert(
requirement,
Element {
id: requirement,
alias: "requirement".into(),
name: "Requirement".into(),
description: String::new(),
parent: None,
body: ElementBody::shared_in(
Layer::Oa,
SharedBody::Constraint {
expression: String::new(),
constrained: Vec::new(),
},
),
},
);
let mut budget = budget();
let units = [unit(source, 8, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(output.definitions.len(), 5);
let point = id("00000000-0000-0000-0000-000000000062");
let interval = id("00000000-0000-0000-0000-000000000063");
let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else {
panic!("quantity kind")
};
assert_eq!(
kind.algebra,
a::QuantityAlgebra::Affine {
difference: a::DefinitionRef::Local(interval)
}
);
let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")];
assert_eq!(unused.description, "保留");
let a::DefinitionBody::Calculation(calculation) = &unused.body else {
panic!("calculation")
};
assert_eq!(calculation.body, a::Binding::Unbound);
assert_eq!(
calculation.parameters[0].value_type,
a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval)))
);
assert_eq!(calculation.result, calculation.parameters[0].value_type);
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.field == Field::DocumentedConstraint
&& occurrence.target == Target::Global(requirement))
);
let override_id = id("00000000-0000-0000-0000-000000000067");
assert!(output.occurrences.iter().any(|occurrence| occurrence.field
== Field::Override(override_id)
&& occurrence.role == Role::OverrideTarget));
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.owner == point
&& occurrence.field == Field::Algebra
&& occurrence.target == Target::Global(interval))
);
admit(&merge(skeleton, output));
}
#[test]
fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() {
let mut budget = budget();
let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)];
let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(3));
assert!(failed.error.message.contains("Duplicate source file"));
let failure = budget
.bytes(usize::MAX, Span { start: 5, end: 6 })
.unwrap_err();
let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err();
assert_eq!(failed.error, failure);
}
#[test]
fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() {
let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127));
let wide = std::iter::repeat_n("1", 1024)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
r#"
calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }}
calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }}
"#
);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let usage = budget.usage;
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(output.expressions.len(), 128 + 1025);
let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body
else {
panic!("deep calculation")
};
let a::Binding::Expression(root) = &deep.body else {
panic!("deep expression")
};
let mut current = root;
let mut depth = 1;
while let a::Expr::Unary { operand, .. } = current {
depth += 1;
current = operand;
}
assert_eq!(depth, 128);
assert_eq!(current, &number(1));
let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body
else {
panic!("wide calculation")
};
let a::Binding::Expression(a::Expr::List(values)) = &wide.body else {
panic!("wide list")
};
assert_eq!(values.len(), 1024);
assert!(values.iter().all(|value| *value == number(1)));
assert_eq!(budget.usage, usage);
admit(&merge(ordinary(ORDINARY_OA), output));
}
@@ -0,0 +1,400 @@
//! Private syntax-only complete-bundle witness. No model admission or edit authority.
//!
//! One scan charges original bytes and raw tokens (including trivia). Typed
//! syntax keeps its existing entry/reference/numeric charges. Ordinary syntax
//! charges one entry per project/declaration/include/description, field, and
//! value before allocation; array values already account for their list slots.
//! These private storage ceilings are independent of caller model record counts.
//! Ordinary references consume value storage, not ordered-model-step allowance.
//! Typed reference occurrences retain the caller max_step_references ceiling.
//! Only declaration graph/record counts are known here; semantic nested-model
//! records and ordinary ordered steps still require the later complete admission.
//! Metadata phase slots aggregate across all files: retained token-end indices,
//! value-end entries, projected value/field/header/declaration positions, lexical
//! owner slots and the file table. Checks do not recharge syntax or latch errors;
//! every failure must return immediately. Tokens themselves use the token bound.
//! This deliberately bounded staging policy is not calibrated public source8.
use super::{
budget::{Budget, Limits},
cursor::Cursor,
declarations, lex,
};
use crate::Id;
use crate::language::{
self, Ast, EditDeclaration, Error, Lexeme, ParseLimits, Parser, SourceUnit, Span, TokenClass,
};
use crate::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic, traversal::Traversal};
use std::collections::BTreeMap;
struct ModelCounts {
limits: ProjectLimits,
graph: usize,
records: usize,
includes: usize,
}
pub(super) struct ParsedMixedSyntax<'s> {
bundle: &'s SourceBundle,
// Preserve caller policy for the later semantic pass. Syntax storage uses
// separate ceilings and cannot stand in for these graph/record/step limits.
limits: ProjectLimits,
files: Vec<FileSyntax<'s>>,
budget: Budget,
}
struct FileSyntax<'s> {
path: &'s str,
source: &'s str,
tokens: Vec<lex::Token>,
ordinary: SourceUnit,
typed: Vec<declarations::Declaration>,
positions: Vec<(Id, EditDeclaration)>,
// A lexical owner is an occurrence index, not only a possibly repeated UUID.
lexical_owners: Vec<Option<usize>>,
}
// Only Parser borrows this state; the stored witness has no self-reference.
pub(in crate::language) struct State<'a> {
source: &'a str,
tokens: &'a [lex::Token],
budget: &'a mut Budget,
model: &'a mut ModelCounts,
typed: Vec<declarations::Declaration>,
pub(in crate::language) value_ends: BTreeMap<usize, usize>,
pub(in crate::language) lexical_owners: Vec<Option<usize>>,
}
fn ordinary_error(error: super::Error) -> Error {
Error {
code: "invalid_source",
offset: error.span.start,
message: error.message,
}
}
fn lexeme(token: lex::Token) -> Lexeme {
Lexeme {
class: match token.class {
lex::Class::Word => TokenClass::Identifier,
lex::Class::Number => TokenClass::Number,
lex::Class::String => TokenClass::String,
lex::Class::Punctuation => TokenClass::Punctuation,
},
span: token.span,
}
}
impl State<'_> {
pub(in crate::language) fn token(&self, at: usize) -> Option<Lexeme> {
self.tokens.get(at).copied().map(lexeme)
}
pub(in crate::language) fn entry(&mut self, at: Span) -> Result<(), Error> {
self.budget.entry(at).map_err(ordinary_error)
}
pub(in crate::language) fn record(&mut self, graph: bool, at: Span) -> Result<(), Error> {
let records = self.model.records.checked_add(1);
let graph = self.model.graph.checked_add(usize::from(graph));
let (Some(records), Some(graph)) = (records, graph) else {
return Err(Error {
code: "model_limit",
offset: at.start,
message: "Declaration count overflow.".into(),
});
};
self.model
.limits
.model
.check_counts(graph, records, 0)
.map_err(|message| Error {
code: "model_limit",
offset: at.start,
message,
})?;
self.model.records = records;
self.model.graph = graph;
Ok(())
}
pub(in crate::language) fn include(&mut self, at: Span) -> Result<(), Error> {
if self.model.includes >= self.model.limits.max_include_edges {
return Err(Error {
code: "project_limit",
offset: at.start,
message: format!(
"Include graph exceeds {} edges.",
self.model.limits.max_include_edges
),
});
}
self.model.includes += 1;
Ok(())
}
pub(in crate::language) fn ordinary_number(
&self,
at: &mut usize,
) -> Result<serde_json::Value, Error> {
let first = self.tokens[*at];
let mut end = first.span.end;
let mut after = *at + 1;
// Match the old greedy numeric alphabet only across byte-adjacent raw
// tokens. This is consumption of the shared stream, not another scan.
while let Some(next) = self.tokens.get(after) {
if next.span.start != end
|| !self.source[next.span.start..next.span.end]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte))
{
break;
}
end = next.span.end;
after += 1;
}
let value: serde_json::Number = serde_json::from_str(&self.source[first.span.start..end])
.map_err(|_| Error {
code: "invalid_source",
offset: first.span.start,
message: "Expected a byte-adjacent finite JSON decimal number.".into(),
})?;
*at = after;
Ok(serde_json::Value::Number(value))
}
pub(in crate::language) fn typed_header(&self, at: usize, root: bool) -> bool {
let Some(token) = self.tokens.get(at) else {
return false;
};
if declarations::declaration_kind(&self.source[token.span.start..token.span.end]).is_none()
{
return false;
}
// A generic field named `unit` remains an ordinary field. A declaration
// header has an alias and display-name token; only that shape is nested.
root || (self
.tokens
.get(at + 1)
.is_some_and(|t| t.class == lex::Class::Word)
&& self
.tokens
.get(at + 2)
.is_some_and(|t| t.class == lex::Class::String))
}
pub(in crate::language) fn parse_typed(
&mut self,
at: &mut usize,
owner: Option<Id>,
) -> Result<(), Error> {
// Transfer the one active position into the typed consumer and back;
// neither consumer scans, clones a stream, nor runs concurrently.
self.record(true, self.tokens[*at].span)?;
let mut cursor = Cursor::new(self.source, self.tokens, self.budget);
cursor.cursor = *at;
let result = declarations::parse(&mut cursor, 8, owner);
*at = cursor.cursor;
let declaration = result.map_err(ordinary_error)?;
let missing = declaration.header.identity.value.is_none()
|| matches!(&declaration.body, declarations::Body::Calculation { parameters, .. }
if parameters.iter().any(|p| p.identity.value.is_none()));
if missing {
return Err(Error {
code: "invalid_source",
offset: declaration.span.start,
message: "Private mixed syntax requires explicit IDs.".into(),
});
}
self.typed.push(declaration);
Ok(())
}
}
pub(in crate::language) fn long_analysis_word(word: &str) -> bool {
matches!(
word,
"oa_engineering_property"
| "sa_engineering_property"
| "oa_analysis_check"
| "sa_analysis_check"
| "oa_analysis_case"
| "sa_analysis_case"
)
}
pub(super) fn parse(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
let profile = Limits::source8(ParseLimits {
// Storage is not the caller's final model record/ordered-step accounting.
max_source_bytes: limits.model.max_source_bytes,
max_step_references: limits.model.max_step_references,
..ParseLimits::UNLIMITED
});
parse_with_profile(bundle, limits, profile)
}
fn located(path: &str, source: &str, error: Error) -> Vec<SourceDiagnostic> {
vec![SourceDiagnostic {
path: path.into(),
diagnostic: language::diagnostic(source, error),
related: Vec::new(),
}]
}
fn parse_with_profile(
bundle: &SourceBundle,
limits: ProjectLimits,
profile: Limits,
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
let entry_source = bundle.files.get(&bundle.entry).map_or("", String::as_str);
let at_entry = |error| located(&bundle.entry, entry_source, ordinary_error(error));
let maximum = Limits::source8(ParseLimits {
max_source_bytes: limits.model.max_source_bytes,
max_step_references: limits.model.max_step_references,
..ParseLimits::UNLIMITED
});
if profile.bytes > maximum.bytes
|| profile.tokens > maximum.tokens
|| profile.entries > maximum.entries
|| profile.references > maximum.references
|| profile.frames > maximum.frames
|| profile.numeric_units > maximum.numeric_units
{
return Err(at_entry(super::error(
Span::default(),
"Mixed test profile may only tighten inherited limits.",
)));
}
let mut budget = Budget::new(profile).map_err(at_entry)?;
let bytes = bundle
.files
.values()
.try_fold(0usize, |n, s| n.checked_add(s.len()))
.ok_or_else(|| at_entry(super::error(Span::default(), "Source size overflow.")))?;
budget
.index_bytes(bytes, Span::default())
.map_err(at_entry)?;
let mut metadata = bundle.files.len();
budget
.lowering_entries(metadata, Span::default())
.map_err(at_entry)?;
// Tighter private byte/file-slot limits precede folded-path table and
// traversal allocations. Public Traversal's own validation order is intact.
let mut traversal = Traversal::new(bundle, limits)?;
let mut parsed = BTreeMap::new();
let mut model = ModelCounts {
limits,
graph: 0,
records: 0,
includes: 0,
};
while let Some(path) = traversal.next()? {
let (path, source) = bundle
.files
.get_key_value(&path)
.expect("traversal checked source");
let failure = |error| located(path, source, ordinary_error(error));
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
let mut parser = Parser {
source,
tokens: Vec::new(),
cursor: 0,
value_depth: 0,
limits: limits.model,
mixed: Some(State {
source,
tokens: &tokens,
budget: &mut budget,
model: &mut model,
typed: Vec::new(),
value_ends: BTreeMap::new(),
lexical_owners: Vec::new(),
}),
};
let ordinary = parser
.unit(path == &bundle.entry, true)
.map_err(|e| located(path, source, e))?;
let state = parser.mixed.take().expect("private parser state");
drop(parser);
// Projected ordinary AST nodes/fields are bounded before constructing a
// second position representation. Count references as slots, not syntax.
let projection_slots = ordinary_position_slots(&ordinary);
metadata = metadata
.checked_add(tokens.len())
.and_then(|n| n.checked_add(state.value_ends.len()))
.and_then(|n| n.checked_add(projection_slots))
.and_then(|n| n.checked_add(state.lexical_owners.len()))
.ok_or_else(|| failure(super::error(Span::default(), "Metadata size overflow.")))?;
state
.budget
.lowering_entries(metadata, Span::default())
.map_err(failure)?;
let positions = language::edit_positions_from_tokens(
source,
&ordinary,
|at| lexeme(tokens[at]),
tokens.len(),
Some(&state.value_ends),
)
.map_err(|e| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *e,
related: Vec::new(),
}]
})?;
for (raw, span) in &ordinary.includes {
traversal.include(path, raw, *span)?;
}
traversal.finish_file(path.clone());
let State {
typed,
lexical_owners,
..
} = state;
parsed.insert(
path.as_str(),
FileSyntax {
path,
source,
tokens,
ordinary,
typed,
positions,
lexical_owners,
},
);
}
traversal.finish()?;
budget.active().map_err(at_entry)?;
Ok(ParsedMixedSyntax {
bundle,
limits,
files: parsed.into_values().collect(),
budget,
})
}
fn ordinary_position_slots(unit: &SourceUnit) -> usize {
// Iterative accounting avoids adding a second recursive walk before the
// already depth-bounded position projection. Each reference is one value.
let mut slots = 0usize;
let mut stack = Vec::new();
for declaration in &unit.declarations {
slots = slots.saturating_add(7); // declaration/body + at most five header roles
slots = slots.saturating_add(declaration.fields.len());
stack.extend(declaration.fields.values());
}
while let Some(value) = stack.pop() {
slots = slots.saturating_add(1);
match value {
Ast::Array(items, _) => stack.extend(items),
Ast::Object(fields, _) => {
slots = slots.saturating_add(fields.len());
stack.extend(fields.values());
}
_ => {}
}
}
slots
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
//! Independent model construction: never parses source or calls the writer.
use crate::analysis::*;
use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter};
use crate::*;
use std::collections::BTreeMap;
pub(super) const NAMES: [(&str, &str); 14] = [
("duration", "Duration"),
("second", "Second"),
("millisecond", "Millisecond"),
("combine", "Combine durations"),
("respond", "Respond"),
("receive", "Receive"),
("dispatch", "Dispatch"),
("receive_time", "Receive time"),
("dispatch_time", "Dispatch time"),
("total_time", "Total time"),
("limit", "Allowed duration"),
("budget", "Response budget"),
("degraded", "Degraded radio"),
("unmeasured", "Unmeasured radio"),
];
fn named(alias: &str) -> String {
NAMES
.iter()
.find(|(key, _)| *key == alias)
.unwrap()
.1
.into()
}
fn local(id: Id) -> DefinitionRef {
DefinitionRef::Local(id)
}
fn quantity(value: i64, unit: Id) -> Expr {
Expr::Quantity {
magnitude: Exact::from_integer(value),
unit: local(unit),
}
}
fn expression(value: Expr) -> Binding {
Binding::Expression(value)
}
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
pub(super) fn project(layer: Layer) -> Project {
// OA is the fixed r2 set; SA independently constructs fresh global IDs.
let id = |text| {
let id = Id::parse_str(text).unwrap();
if layer == Layer::Oa {
id
} else {
Id::from_u128(id.as_u128() ^ (1 << 127))
}
};
let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d");
let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767");
let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce");
let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
// Parameter IDs may be reused in another calculation/project.
let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap());
let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap());
let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59");
let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353");
let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b");
let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450");
let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace");
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec");
let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf");
let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93");
let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde");
let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration)));
let definition = |id, alias: &str, body| Definition {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
body,
};
let shared = |id, alias: &str, body| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent: None,
body: ElementBody::shared_in(layer, body),
};
let work = |id, alias: &str, parent| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent,
body: if layer == Layer::Oa {
ElementBody::Oa(OaBody::Activity)
} else {
ElementBody::Sa(SaBody::Function)
},
};
let definitions = [
definition(
duration,
"duration",
DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
),
definition(
second,
"second",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_integer(1),
offset: Exact::from_integer(0),
symbol: "s".into(),
}),
),
definition(
millisecond,
"millisecond",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "1000".into(),
},
&mut WorkMeter::new(WorkLimits::REFERENCE),
)
.unwrap(),
offset: Exact::from_integer(0),
symbol: "ms".into(),
}),
),
definition(
combine,
"combine",
DefinitionBody::Calculation(CalculationDefinition {
parameters: vec![
Parameter {
id: a,
name: "a".into(),
value_type: duration_type(),
},
Parameter {
id: b,
name: "b".into(),
value_type: duration_type(),
},
],
result: duration_type(),
body: expression(binary(
BinaryOp::Add,
Expr::Parameter(a),
Expr::Parameter(b),
)),
}),
),
]
.into_iter()
.map(|definition| (definition.id, definition))
.collect();
let property = |id, alias, subject, display, base| {
shared(
id,
alias,
SharedBody::EngineeringProperty {
subject: AnalysisSubject::Element(subject),
value_type: duration_type(),
display_unit: Some(local(display)),
base: expression(base),
},
)
};
let case = |id, alias, binding| {
shared(
id,
alias,
SharedBody::AnalysisCase {
subject: None,
base: BaselineBase::ModelBaseline,
overrides: BTreeMap::from([(receive_time, binding)]),
},
)
};
let elements = [
work(respond, "respond", None),
work(receive, "receive", Some(respond)),
work(dispatch, "dispatch", Some(respond)),
property(
receive_time,
"receive_time",
receive,
millisecond,
quantity(750, millisecond),
),
property(
dispatch_time,
"dispatch_time",
dispatch,
second,
quantity(1, second),
),
property(
total,
"total_time",
respond,
second,
Expr::Call {
definition: local(combine),
arguments: BTreeMap::from([
(a, Expr::Property(receive_time)),
(b, Expr::Property(dispatch_time)),
]),
},
),
property(limit, "limit", respond, second, quantity(2, second)),
shared(
check,
"budget",
SharedBody::AnalysisCheck {
subject: None,
documented_constraint: None,
predicate: expression(binary(
BinaryOp::LessOrEqual,
Expr::Property(total),
Expr::Property(limit),
)),
},
),
case(
degraded,
"degraded",
expression(quantity(1250, millisecond)),
),
case(unmeasured, "unmeasured", Binding::Unbound),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
Project {
schema_version: 8,
id: project_id,
alias: "response_budget".into(),
name: "Response budget".into(),
description: String::new(),
definitions,
elements,
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
@@ -0,0 +1,473 @@
//! Iterative Pratt machine. Source nesting does not recurse on the native stack.
use super::cursor::Cursor;
use super::*;
use lex::Class;
use std::collections::BTreeSet;
enum SequenceKind {
List,
If,
Sum,
Unary(UnaryOp),
Call(Reference),
}
struct Sequence {
kind: SequenceKind,
start: usize,
values: Vec<(Option<Reference>, Expression)>,
keys: BTreeSet<String>,
}
enum Task {
Expression(u8),
Continue(u8),
Binary(Expression, BinaryOp),
Unary(usize, UnaryOp),
Group(usize),
Next(Sequence),
Collected(Sequence, Option<Reference>),
Power(usize),
AsKind(usize, Reference),
}
pub(super) fn binding(p: &mut Cursor<'_, '_>) -> Result<Binding, Error> {
p.budget.active()?;
if p.text() == "unbound" {
let span = p.span();
p.cursor += 1;
return Ok(Binding::Unbound(span));
}
let mut tasks = vec![Task::Expression(0)];
let mut values = Vec::new();
while let Some(task) = tasks.pop() {
p.budget.active()?;
match task {
Task::Expression(minimum) => {
let start = p.span().start;
if p.text() != "(" {
p.budget.entry(p.span())?;
}
tasks.push(Task::Continue(minimum));
match p.text() {
"true" | "false" => {
let value = p.text() == "true";
let span = p.span();
p.cursor += 1;
values.push(p.node(ExpressionKind::Boolean(value), span)?);
}
"-" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Unary(start, UnaryOp::Negate));
tasks.push(Task::Expression(7));
}
"(" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Group(start));
tasks.push(Task::Expression(0));
}
"[" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(SequenceKind::List, start)));
}
"@" => {
p.cursor += 1;
let reference = p.reference()?;
if p.text() == "(" {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(
SequenceKind::Call(reference),
start,
)));
} else {
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Property(reference), Span { start, end })?,
);
}
}
"$" => {
p.cursor += 1;
let reference = p.reference()?;
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Parameter(reference), Span { start, end })?,
);
}
"ratio" => {
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
"abs" | "not" | "if" | "sum" => {
let kind = match p.text() {
"abs" => SequenceKind::Unary(UnaryOp::Absolute),
"not" => SequenceKind::Unary(UnaryOp::Not),
"if" => SequenceKind::If,
_ => SequenceKind::Sum,
};
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Next(Sequence::new(kind, start)));
}
"power" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Power(start));
tasks.push(Task::Expression(0));
}
"as_kind" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
p.take("@")?;
let kind = p.reference()?;
p.take(",")?;
tasks.push(Task::AsKind(start, kind));
tasks.push(Task::Expression(0));
}
_ if p
.tokens
.get(p.cursor)
.is_some_and(|token| token.class == Class::Number) =>
{
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
_ => return Err(error(p.span(), "Expected an analysis expression.")),
}
}
Task::Continue(minimum) => {
if let Some((op, precedence)) =
operator(p.text()).filter(|(_, prec)| *prec >= minimum)
{
let left = values.pop().expect("completed Pratt operand");
if comparison(op) && ungrouped_comparison(&left) {
return Err(error(p.span(), "Comparison children require parentheses."));
}
p.budget.entry(p.span())?;
p.cursor += 1;
tasks.push(Task::Continue(minimum));
tasks.push(Task::Binary(left, op));
tasks.push(Task::Expression(precedence + 1));
}
}
Task::Binary(left, op) => {
let right = values.pop().expect("completed right operand");
if comparison(op) && ungrouped_comparison(&right) {
return Err(error(
right.span,
"Comparison children require parentheses.",
));
}
let span = Span {
start: left.span.start,
end: right.span.end,
};
values.push(p.node(
ExpressionKind::Binary {
op,
left: Box::new(left),
right: Box::new(right),
},
span,
)?);
}
Task::Unary(start, op) => {
let operand = values.pop().expect("completed unary operand");
let end = operand.span.end;
p.close();
values.push(p.node(
ExpressionKind::Unary {
op,
operand: Box::new(operand),
},
Span { start, end },
)?);
}
Task::Group(start) => {
let end = p.take(")")?.end;
p.close();
let value = values.last_mut().expect("completed grouped expression");
value.grouped = true;
value.span = Span { start, end };
}
Task::Next(mut sequence) => {
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() == closing {
let end = p.take(closing)?.end;
p.close();
values.push(p.sequence(sequence, end)?);
} else {
let count = sequence.values.len();
let maximum = match sequence.kind {
SequenceKind::List => crate::analysis::MAX_LIST_LENGTH,
SequenceKind::Unary(_) => 1,
SequenceKind::If => 3,
SequenceKind::Sum => 2,
SequenceKind::Call(_) => usize::MAX,
};
if count >= maximum {
return Err(error(
p.span(),
"Too many list members or constructor arguments.",
));
}
let key = if matches!(sequence.kind, SequenceKind::Call(_)) {
p.budget.entry(p.span())?;
let key = p.reference()?;
if !sequence.keys.insert(key.name.clone()) {
return Err(error(key.span, "Duplicate call argument name."));
}
p.take(":")?;
Some(key)
} else {
None
};
tasks.push(Task::Collected(sequence, key));
tasks.push(Task::Expression(0));
}
}
Task::Collected(mut sequence, key) => {
sequence
.values
.push((key, values.pop().expect("completed sequence value")));
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() != closing {
p.take(",")?;
if p.text() == closing {
return Err(error(p.span(), "Trailing comma is not an expression."));
}
}
tasks.push(Task::Next(sequence));
}
Task::Power(start) => {
let base = values.pop().expect("completed power base");
p.take(",")?;
let negative = if p.text() == "-" {
p.cursor += 1;
true
} else {
false
};
let span = p.integer()?;
let magnitude = p.source[span.start..span.end]
.parse::<u8>()
.map_err(|_| error(span, "Power exponent is outside -16..16."))?;
if magnitude > 16 {
return Err(error(span, "Power exponent is outside -16..16."));
}
let exponent = if negative {
-(magnitude as i8)
} else {
magnitude as i8
};
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::Power {
base: Box::new(base),
exponent,
},
Span { start, end },
)?);
}
Task::AsKind(start, kind) => {
let value = values.pop().expect("completed kind assertion value");
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::AsKind {
kind,
value: Box::new(value),
},
Span { start, end },
)?);
}
}
}
Ok(Binding::Expression(
values.pop().expect("one complete expression"),
))
}
impl Sequence {
fn new(kind: SequenceKind, start: usize) -> Self {
Self {
kind,
start,
values: Vec::new(),
keys: BTreeSet::new(),
}
}
}
impl Cursor<'_, '_> {
fn number(&mut self, magnitude: Exact, literal: LiteralSyntax) -> Result<Expression, Error> {
let Span { start, end } = literal.span;
let mut expression = if self.text() == "[" {
self.open()?;
self.cursor += 1;
self.take("@")?;
let unit = self.reference()?;
let end = self.take("]")?.end;
self.close();
self.node(
ExpressionKind::Quantity { magnitude, unit },
Span { start, end },
)
} else {
self.node(ExpressionKind::Number(magnitude), Span { start, end })
}?;
expression.literal = Some(literal);
Ok(expression)
}
fn node(&mut self, kind: ExpressionKind, span: Span) -> Result<Expression, Error> {
let child_depth = match &kind {
ExpressionKind::Unary { operand, .. } => operand.depth,
ExpressionKind::Binary { left, right, .. } => left.depth.max(right.depth),
ExpressionKind::If {
condition,
when_true,
when_false,
} => condition.depth.max(when_true.depth).max(when_false.depth),
ExpressionKind::List(values) => values.iter().map(|v| v.depth).max().unwrap_or(0),
ExpressionKind::Call { arguments, .. } => {
arguments.iter().map(|a| a.value.depth).max().unwrap_or(0)
}
ExpressionKind::Sum { values, initial } => values.depth.max(initial.depth),
ExpressionKind::Power { base, .. } => base.depth,
ExpressionKind::AsKind { value, .. } => value.depth,
_ => 0,
};
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
// The rejected parent still owns accepted, potentially depth-128
// children. Recursive derived cleanup here runs beneath the parser
// frames and can exhaust the bounded native stack before returning
// the depth error. Consume those children with an explicit worklist.
drop_rejected_kind(kind);
return Err(error(span, "Expression logical depth exceeds 128."));
}
Ok(Expression {
kind,
span,
depth: child_depth + 1,
grouped: false,
literal: None,
})
}
fn sequence(&mut self, sequence: Sequence, end: usize) -> Result<Expression, Error> {
let span = Span {
start: sequence.start,
end,
};
let mut values = sequence.values.into_iter();
let mut next = || {
values
.next()
.map(|(_, value)| Box::new(value))
.ok_or_else(|| error(span, "Missing required constructor argument."))
};
let kind = match sequence.kind {
SequenceKind::If => ExpressionKind::If {
condition: next()?,
when_true: next()?,
when_false: next()?,
},
SequenceKind::Sum => ExpressionKind::Sum {
values: next()?,
initial: next()?,
},
SequenceKind::Unary(op) => ExpressionKind::Unary {
op,
operand: next()?,
},
SequenceKind::List => ExpressionKind::List(values.map(|(_, value)| value).collect()),
SequenceKind::Call(definition) => ExpressionKind::Call {
definition,
arguments: values
.map(|(parameter, value)| Argument {
parameter: parameter.expect("parsed named argument"),
value,
})
.collect(),
},
};
self.node(kind, span)
}
}
fn drop_rejected_kind(kind: ExpressionKind) {
let mut pending = vec![kind];
while let Some(kind) = pending.pop() {
match kind {
ExpressionKind::Unary { operand, .. } => pending.push(operand.kind),
ExpressionKind::Binary { left, right, .. } => {
pending.push(left.kind);
pending.push(right.kind);
}
ExpressionKind::If {
condition,
when_true,
when_false,
} => {
pending.push(condition.kind);
pending.push(when_true.kind);
pending.push(when_false.kind);
}
ExpressionKind::List(values) => pending.extend(values.into_iter().map(|v| v.kind)),
ExpressionKind::Call { arguments, .. } => {
pending.extend(arguments.into_iter().map(|a| a.value.kind));
}
ExpressionKind::Sum { values, initial } => {
pending.push(values.kind);
pending.push(initial.kind);
}
ExpressionKind::Power { base, .. } => pending.push(base.kind),
ExpressionKind::AsKind { value, .. } => pending.push(value.kind),
ExpressionKind::Boolean(_)
| ExpressionKind::Number(_)
| ExpressionKind::Quantity { .. }
| ExpressionKind::Property(_)
| ExpressionKind::Parameter(_) => {}
}
}
}
fn comparison(op: BinaryOp) -> bool {
matches!(
op,
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual
)
}
fn ungrouped_comparison(value: &Expression) -> bool {
!value.grouped && matches!(&value.kind, ExpressionKind::Binary { op, .. } if comparison(*op))
}
fn operator(text: &str) -> Option<(BinaryOp, u8)> {
Some(match text {
"or" => (BinaryOp::Or, 1),
"and" => (BinaryOp::And, 2),
"==" => (BinaryOp::Equal, 3),
"!=" => (BinaryOp::NotEqual, 3),
"<" => (BinaryOp::Less, 4),
"<=" => (BinaryOp::LessOrEqual, 4),
">" => (BinaryOp::Greater, 4),
">=" => (BinaryOp::GreaterOrEqual, 4),
"+" => (BinaryOp::Add, 5),
"-" => (BinaryOp::Subtract, 5),
"*" => (BinaryOp::Multiply, 6),
"/" => (BinaryOp::Divide, 6),
_ => return None,
})
}
@@ -0,0 +1,546 @@
use super::*;
use crate::language::ParseLimits;
use budget::{Budget, Limits};
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn expression(source: &str) -> Expression {
match parse(source, &mut budget()).unwrap() {
Binding::Expression(value) => value,
Binding::Unbound(_) => panic!("expected expression"),
}
}
fn number(value: &Expression) -> String {
let ExpressionKind::Number(exact) = &value.kind else {
panic!("expected Number");
};
exact.canonical_literal()
}
fn binary_children(value: Expression, expected: BinaryOp) -> (Expression, Expression) {
let ExpressionKind::Binary { op, left, right } = value.kind else {
panic!("expected Binary {expected:?}");
};
assert_eq!(op, expected);
(*left, *right)
}
#[test]
fn mixed_precedence_and_grouping_have_independent_expected_child_topology() {
let (one, product) = binary_children(expression("1 + 2 * 3"), BinaryOp::Add);
assert_eq!(number(&one), "1");
let (two, three) = binary_children(product, BinaryOp::Multiply);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
let (conjunction, last) =
binary_children(expression("not(false) and true or false"), BinaryOp::Or);
assert_eq!(last.kind, ExpressionKind::Boolean(false));
let (negated, middle) = binary_children(conjunction, BinaryOp::And);
assert_eq!(middle.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Unary { op, operand } = negated.kind else {
panic!("expected Not");
};
assert_eq!(op, UnaryOp::Not);
assert_eq!(operand.kind, ExpressionKind::Boolean(false));
let (negative, two) = binary_children(expression("-1 * 2"), BinaryOp::Multiply);
assert_eq!(number(&two), "2");
let ExpressionKind::Unary { op, operand } = negative.kind else {
panic!("expected Negate");
};
assert_eq!(op, UnaryOp::Negate);
assert_eq!(number(&operand), "1");
let (quotient, three) = binary_children(expression("1 / 2 * 3"), BinaryOp::Multiply);
assert_eq!(number(&three), "3");
let (one, two) = binary_children(quotient, BinaryOp::Divide);
assert_eq!(
(number(&one), number(&two)),
("1".to_owned(), "2".to_owned())
);
let (one, grouped) = binary_children(expression("1 + (2 + 3)"), BinaryOp::Add);
assert_eq!(number(&one), "1");
assert!(grouped.grouped);
let (two, three) = binary_children(grouped, BinaryOp::Add);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
}
#[test]
fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children() {
let flat = |count: usize| vec!["true"; count].join(" and ");
let deep = flat(128);
let mut tree = expression(&deep);
assert_eq!(tree.depth, 128);
// Independently verify the complete left-associated shape, not just depth.
for _ in 1..128 {
let (left, right) = binary_children(tree, BinaryOp::And);
assert_eq!(right.kind, ExpressionKind::Boolean(true));
tree = left;
}
assert_eq!(tree.kind, ExpressionKind::Boolean(true));
assert!(
parse(&flat(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
// The first argument is already a complete depth128 tree when parsing the
// second fails. Refusal tears down the held child without building Sum.
let malformed = format!("sum({deep}, @)");
let failure = parse(&malformed, &mut budget()).unwrap_err();
assert_eq!(
failure.message,
"Expected a valid reference or parameter name."
);
assert_eq!(&malformed[failure.span.start..failure.span.end], ")");
assert_eq!(number(&expression("1")), "1");
}
#[test]
fn every_parent_shape_rejects_depth129_and_cleans_children_on_128k_stack() {
std::thread::Builder::new()
.name("analysis-syntax-rejection-128k".into())
.stack_size(128 * 1024)
.spawn(|| {
// Rotate through every recursive syntax shape, so the rejected
// parent's complete child tree also exercises every cleanup arm.
let wrap = |shape, child: &str| match shape {
0 => format!("abs({child})"),
1 => format!("({child}) + 1"),
2 => format!("if(true, {child}, 0)"),
3 => format!("[{child}]"),
4 => format!("@f(a: {child})"),
5 => format!("sum({child}, 0)"),
6 => format!("power({child}, 1)"),
7 => format!("as_kind(@k, {child})"),
_ => unreachable!(),
};
let mut deep = "1".to_owned();
for layer in 0..127 {
deep = wrap(layer % 8, &deep);
}
assert_eq!(expression(&deep).depth, 128);
for shape in 0..8 {
let source = wrap(shape, &deep);
let failure = parse(&source, &mut budget()).unwrap_err();
assert_eq!(failure.message, "Expression logical depth exceeds 128.");
assert_eq!(
failure.span,
Span {
start: 0,
end: source.len()
}
);
assert_eq!(number(&expression("1")), "1");
}
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
let parsed = expression(source);
assert_eq!(
parsed.span,
Span {
start: 0,
end: source.len()
}
);
let literal = parsed.literal.as_ref().unwrap();
assert!(source[literal.span.start..literal.span.end].contains("// denominator"));
assert_eq!(
literal
.tokens
.iter()
.map(|span| &source[span.start..span.end])
.collect::<Vec<_>>(),
["ratio", "(", "-", "3", ",", "4", ")"]
);
let ExpressionKind::Quantity { magnitude, unit } = parsed.kind else {
panic!("expected Quantity");
};
assert_eq!(magnitude.canonical_literal(), "ratio(-3, 4)");
assert_eq!(unit.name, "millisecond");
assert_eq!(&source[unit.span.start..unit.span.end], "millisecond");
let mut renamed = source.to_owned();
renamed.replace_range(unit.span.start..unit.span.end, "ms");
assert_eq!(renamed, source.replacen("millisecond", "ms", 1));
assert_eq!(
number(&expression("ratio (1, // rationale\n 2)")),
"ratio(1, 2)"
);
for invalid in [
"1 e - 2",
"1e // x\n -2",
"1 . 2",
"ratio(1 + 2, 3)",
"ratio(1, -2)",
"ratio(+1, 2)",
"ratio(1.0, 2)",
"ratio(1, 0)",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn raw_numeric_lexing_preserves_exact_atoms_and_operator_boundaries() {
let source = "1-2 1e-2 ratio(-1,2) -(1) <= >= == !=";
let tokens = lex::tokens(source, &mut budget()).unwrap();
let text: Vec<_> = tokens
.iter()
.map(|t| &source[t.span.start..t.span.end])
.collect();
assert_eq!(
text,
[
"1", "-", "2", "1e-2", "ratio", "(", "-", "1", ",", "2", ")", "-", "(", "1", ")", "<=",
">=", "==", "!="
]
);
let huge = "9".repeat(400);
assert_eq!(
lex::tokens(&huge, &mut budget()).unwrap()[0].class,
lex::Class::Number
);
assert_eq!(number(&expression(&huge)), huge);
for invalid in ["1e", "1e+", "1_0", "0x1", "1.2.3"] {
assert!(lex::tokens(invalid, &mut budget()).is_err(), "{invalid}");
}
let quoted = "\"quoted\\\" café\"";
let tokens = lex::tokens(quoted, &mut budget()).unwrap();
assert_eq!(tokens[0].class, lex::Class::String);
assert_eq!(&quoted[tokens[0].span.start..tokens[0].span.end], quoted);
}
#[test]
fn unary_literals_precedence_and_comparison_children_keep_distinct_trees() {
assert_eq!(number(&expression("ratio(-1, 1)")), "ratio(-1, 1)");
let negative = expression("-(1)");
assert!(matches!(
negative.kind,
ExpressionKind::Unary {
op: UnaryOp::Negate,
..
}
));
let left = expression("(1 - 2) - 3");
let right = expression("1 - (2 - 3)");
let ExpressionKind::Binary {
left, right: last, ..
} = left.kind
else {
panic!();
};
assert!(matches!(
left.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
assert_eq!(number(&last), "3");
let ExpressionKind::Binary {
left: first, right, ..
} = right.kind
else {
panic!();
};
assert_eq!(number(&first), "1");
assert!(matches!(
right.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
for valid in [
"(1 < 2) == true",
"(true == false) == true",
"true == (1 < 2)",
"1 + 2 * 3",
"not(false) and true or false",
] {
parse(valid, &mut budget()).unwrap();
}
for invalid in [
"1 < 2 == true",
"true == false == true",
"1 < 2 < 3",
"true == 1 < 2",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn every_expression_shape_and_exact_constructor_arity_is_represented() {
let tree = expression("if(true, sum([@p, $x], 0), as_kind(@duration, power(abs(-(2)), -2)))");
let ExpressionKind::If {
condition,
when_true,
when_false,
} = tree.kind
else {
panic!();
};
assert_eq!(condition.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Sum { values, initial } = when_true.kind else {
panic!();
};
assert_eq!(number(&initial), "0");
let ExpressionKind::List(values) = values.kind else {
panic!();
};
assert!(matches!(&values[0].kind, ExpressionKind::Property(r) if r.name == "p"));
assert!(matches!(&values[1].kind, ExpressionKind::Parameter(r) if r.name == "x"));
let ExpressionKind::AsKind { kind, value } = when_false.kind else {
panic!();
};
assert_eq!(kind.name, "duration");
assert!(matches!(
value.kind,
ExpressionKind::Power { exponent: -2, .. }
));
assert!(matches!(expression("[]").kind, ExpressionKind::List(v) if v.is_empty()));
assert!(
matches!(expression("@zero()").kind, ExpressionKind::Call { arguments, .. } if arguments.is_empty())
);
assert!(matches!(
parse(" unbound // pending", &mut budget()).unwrap(),
Binding::Unbound(_)
));
for invalid in [
"sum([1])",
"sum([1], 0, 2)",
"if(true, 1)",
"abs()",
"not(true, false)",
"power(2, 17)",
"power(2, 1.0)",
"as_kind(duration, 1)",
"[1,]",
"[1 2]",
"(1+2)[@unit]",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn repeated_call_argument_occurrences_have_distinct_original_spans() {
let source = "@combine(a: @p, b: 2) + @combine(a: @q, b: 4)";
let tree = expression(source);
let ExpressionKind::Binary { left, right, .. } = tree.kind else {
panic!();
};
let ExpressionKind::Call {
definition: first,
arguments: a,
} = left.kind
else {
panic!();
};
let ExpressionKind::Call {
definition: second,
arguments: b,
} = right.kind
else {
panic!();
};
assert_eq!(first.name, second.name);
assert_ne!(first.span, second.span);
assert_eq!(a[0].parameter.name, b[0].parameter.name);
assert_ne!(a[0].parameter.span, b[0].parameter.span);
for argument in a.iter().chain(&b) {
assert_eq!(
&source[argument.parameter.span.start..argument.parameter.span.end],
argument.parameter.name
);
}
assert!(
parse("@combine(a: 1, a: 2)", &mut budget())
.unwrap_err()
.message
.contains("Duplicate")
);
assert!(parse("@combine(a: 1, b:)", &mut budget()).is_err());
// Resolution to owner-qualified ParameterId and actual source rewrites are
// deliberately a later stage; this oracle establishes unique syntax spans.
}
#[test]
fn budgets_aggregate_across_fragments_and_latch_numeric_exhaustion() {
let mut first = budget();
parse("ratio(1, 3)", &mut first).unwrap();
let cost = first.numeric_units();
assert!(cost > 0);
let mut settings = limits();
settings.numeric_units = cost;
let mut shared = Budget::new(settings).unwrap();
parse("ratio (1, // same numeric cost\n 3)", &mut shared).unwrap();
assert_eq!(shared.numeric_units(), cost);
let failure = parse("2", &mut shared).unwrap_err();
let usage = shared.usage;
assert_eq!(parse("unbound", &mut shared).unwrap_err(), failure);
assert_eq!(shared.usage, usage);
parse("2", &mut budget()).unwrap();
let mut settings = limits();
settings.entries = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
parse("false", &mut shared).unwrap();
assert!(
parse("true", &mut shared)
.unwrap_err()
.message
.contains("entries")
);
assert_eq!(shared.usage.entries, 2);
let mut settings = limits();
settings.references = 1;
let mut shared = Budget::new(settings).unwrap();
parse("@one", &mut shared).unwrap();
assert!(
parse("$two", &mut shared)
.unwrap_err()
.message
.contains("references")
);
assert_eq!(shared.usage.references, 1);
}
#[test]
fn byte_token_frame_logical_and_list_boundaries_have_nearby_successes() {
let mut settings = limits();
settings.bytes = 4;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
assert!(
parse(" ", &mut shared)
.unwrap_err()
.message
.contains("bytes")
);
assert_eq!(shared.usage.bytes, 4);
let mut settings = limits();
settings.tokens = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true ", &mut shared).unwrap();
assert!(
parse("// note", &mut shared)
.unwrap_err()
.message
.contains("tokens")
);
assert_eq!(shared.usage.tokens, 2);
let nested = |depth: usize| format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
let mut shared = budget();
assert_eq!(
match parse(&nested(256), &mut shared).unwrap() {
Binding::Expression(e) => e.depth,
_ => 0,
},
1
);
assert_eq!(shared.usage.deepest_frame, 256);
assert!(
parse(&nested(257), &mut budget())
.unwrap_err()
.message
.contains("frames")
);
let unary = |depth: usize| format!("{}true{}", "not(".repeat(depth - 1), ")".repeat(depth - 1));
assert_eq!(expression(&unary(128)).depth, 128);
assert!(
parse(&unary(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
let list = |count: usize| format!("[{}]", vec!["true"; count].join(","));
parse(&list(1024), &mut budget()).unwrap();
assert!(parse(&list(1025), &mut budget()).is_err());
let lowered = Limits::source8(ParseLimits {
max_source_bytes: 8,
max_graph_objects: 3,
max_records: 5,
max_step_references: 2,
});
assert_eq!(
(
lowered.bytes,
lowered.tokens,
lowered.entries,
lowered.references
),
(8, 8, 5, 2)
);
let mut above = limits();
above.frames += 1;
assert!(Budget::new(above).is_err());
}
#[test]
fn partial_wide_and_deep_input_cleanup_does_not_poison_a_fresh_parse() {
let source = "@f(a: if(true, [ratio (1, //why\n 2), @p], sum([1, 2], 0)), b: power(2, -3))";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens.iter().filter(|token| {
matches!(
&source[token.span.start..token.span.end],
"(" | "[" | "," | ":" | "@"
)
}) {
assert!(parse(&source[..token.span.end], &mut budget()).is_err());
assert_eq!(number(&expression("ratio(2, 3)")), "ratio(2, 3)");
}
for malformed in [
format!("{}true", "(".repeat(256)),
format!("[{}", vec!["true"; 1024].join(",")),
"-".repeat(10_000),
] {
assert!(parse(&malformed, &mut budget()).is_err());
parse("true", &mut budget()).unwrap();
}
}
#[test]
fn public_source_headers_and_existing_numeric_token_controls_are_unchanged() {
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for accepted in ["-1.5", "1e-2", "0", "-0"] {
let tokens = crate::language::tokenize(accepted);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].class, crate::language::TokenClass::Number);
assert_eq!(
tokens[0].span,
Span {
start: 0,
end: accepted.len()
}
);
}
for rejected in ["+1", "1_0", "0x1", "1e"] {
assert!(
crate::language::tokenize(rejected)
.iter()
.any(|t| t.class == crate::language::TokenClass::Error)
);
}
}
@@ -0,0 +1,316 @@
//! Simultaneous typed token edits. This is a staged plan, not source acceptance:
//! the bundle owner must merge ordinary edits, reparse/recompile the exact bytes,
//! admit once and compare the complete intended Project before installation.
use super::index::{Index, Location, OccurrenceKey, checked_source};
use super::lower::Target;
use super::{Budget, Error, Span, error};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
struct Edit {
key: OccurrenceKey,
location: Location,
}
#[derive(Debug)]
pub(super) struct Plan<'i, 's> {
index: &'i Index<'s>,
names: BTreeMap<Target, String>,
edits: Vec<Edit>,
}
impl<'i, 's> Plan<'i, 's> {
/// All names are the desired final names. Validate the final namespaces,
/// rather than applying one rename at a time: alias/parameter swaps are legal.
pub fn renames(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<Self, Error> {
let plan = Self::prepare(index, renames, budget)?;
plan.validate(index.sources(), budget)?;
plan.output_lengths(index.sources(), budget)?;
Ok(plan)
}
fn validate_names(
index: &Index<'_>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<(), Error> {
budget.active()?;
budget.lowering_entries(renames.len(), Span::default())?;
let mut bytes = 0usize;
for (target, name) in renames {
if !index.names().contains_key(target) {
return Err(error(
Span::default(),
"Rename has no qualified source target.",
));
}
if !crate::valid_alias(name) {
return Err(error(
Span::default(),
"Invalid alias or scoped parameter name.",
));
}
bytes = bytes
.checked_add(name.len())
.ok_or_else(|| error(Span::default(), "Rename size overflow."))?;
budget.index_bytes(bytes, Span::default())?;
}
let mut final_names = BTreeSet::new();
for (target, original) in index.names() {
let name = renames.get(target).unwrap_or(original);
let scope = match target {
Target::Global(_) => None,
Target::Parameter { calculation, .. } => Some(*calculation),
};
budget.lowering_entries(final_names.len() + 1, Span::default())?;
if !final_names.insert((scope, name.as_str())) {
return Err(error(
Span::default(),
"Rename creates a duplicate name in its namespace.",
));
}
}
Ok(())
}
fn prepare(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<Self, Error> {
// Validate record/name storage before copying any owned final names.
Self::validate_names(index, renames, budget)?;
let mut edits = Vec::new();
for (key, location) in index.occurrences() {
if renames
.get(&key.target)
.is_some_and(|name| index.names().get(&key.target) != Some(name))
{
budget.lowering_occurrences(edits.len() + 1, location.span)?;
edits.push(Edit {
key: key.clone(),
location: *location,
});
}
}
edits.sort_by_key(|edit| {
(
edit.location.file,
edit.location.span.start,
edit.location.span.end,
)
});
Ok(Self {
index,
names: renames.clone(),
edits,
})
}
pub fn edit_count(&self) -> usize {
self.edits.len()
}
/// Recheck the complete original witness and complete rename intent.
/// A changed unrelated file is stale too. This does not construct output.
fn validate(&self, current: &BTreeMap<usize, String>, budget: &Budget) -> Result<(), Error> {
budget.active()?;
if current != self.index.sources() {
return Err(error(
Span::default(),
"Source index is stale: original file set or bytes changed.",
));
}
budget.lowering_entries(current.len(), Span::default())?;
budget.lowering_occurrences(self.edits.len(), Span::default())?;
let mut previous: Option<Location> = None;
let mut seen = BTreeSet::new();
for edit in &self.edits {
let at = edit.location;
if self.index.occurrences().get(&edit.key) != Some(&at) || !seen.insert(&edit.key) {
return Err(error(at.span, "Forged or duplicate edit occurrence."));
}
let old = checked_source(current, at)?;
if self
.index
.names()
.get(&edit.key.target)
.is_none_or(|name| name != old)
{
return Err(error(at.span, "Stale original token spelling."));
}
self.names
.get(&edit.key.target)
.ok_or_else(|| error(at.span, "Edit has no final target name."))?;
if previous.is_some_and(|prior| {
prior.file > at.file || (prior.file == at.file && prior.span.end > at.span.start)
}) {
return Err(error(at.span, "Overlapping or unordered source edits."));
}
previous = Some(at);
}
for (key, at) in self.index.occurrences() {
let required = self
.names
.get(&key.target)
.is_some_and(|name| self.index.names().get(&key.target) != Some(name));
if required != seen.contains(key) {
return Err(error(
at.span,
"Edit set differs from the complete qualified rename plan.",
));
}
}
Ok(())
}
/// Standalone output preflight, after ownership validation. Composition
/// defers this hypothetical typed-only size to its future combined emitter.
fn output_lengths(
&self,
current: &BTreeMap<usize, String>,
budget: &Budget,
) -> Result<BTreeMap<usize, usize>, Error> {
let mut lengths: BTreeMap<_, _> = current
.iter()
.map(|(&file, source)| (file, source.len()))
.collect();
for edit in &self.edits {
let at = edit.location;
let length = lengths.get_mut(&at.file).expect("validated edit file");
*length = length
.checked_sub(at.span.end - at.span.start)
.and_then(|size| size.checked_add(self.names[&edit.key.target].len()))
.ok_or_else(|| error(at.span, "Updated source size overflow."))?;
}
let mut total = 0usize;
for length in lengths.values() {
total = total
.checked_add(*length)
.ok_or_else(|| error(Span::default(), "Updated bundle size overflow."))?;
budget.index_bytes(total, Span::default())?;
}
Ok(lengths)
}
pub fn apply(
&self,
current: &BTreeMap<usize, String>,
budget: &Budget,
) -> Result<BTreeMap<usize, String>, Error> {
self.validate(current, budget)?;
let lengths = self.output_lengths(current, budget)?;
let mut result = BTreeMap::new();
let mut edits = self.edits.iter().peekable();
for (&file, source) in current {
let mut output = String::with_capacity(lengths[&file]);
let mut start = 0;
while edits.peek().is_some_and(|edit| edit.location.file == file) {
let edit = edits.next().expect("peeked edit");
output.push_str(&source[start..edit.location.span.start]);
output.push_str(&self.names[&edit.key.target]);
start = edit.location.span.end;
}
output.push_str(&source[start..]);
result.insert(file, output);
}
Ok(result)
}
}
/// Private typed-only composition planning. The caller must supply its inherited
/// parse Budget; this type keeps that same immutable budget through export.
/// It does not establish the future complete bundle/file-table/parse-session
/// witness. In particular, an Index currently does not store its parse profile.
pub(super) struct Composition<'b, 'i, 's> {
plan: Plan<'i, 's>,
budget: &'b Budget,
}
impl std::fmt::Debug for Composition<'_, '_, '_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Composition")
.field("plan", &self.plan)
.finish_non_exhaustive()
}
}
impl<'b, 'i, 's> Composition<'b, 'i, 's> {
pub fn renames(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
inherited: &'b Budget,
) -> Result<Self, Error> {
let plan = Plan::prepare(index, renames, inherited)?;
plan.validate(index.sources(), inherited)?;
Ok(Self {
plan,
budget: inherited,
})
}
/// Export the entire validated plan against exact original numeric file keys
/// and bytes. There is no output allocation, application or subset-accept API.
/// A future collector must check this actual index witness and the complete
/// combined output bounds before applying either producer's edits once.
pub fn original_edits(
&self,
current: &BTreeMap<usize, String>,
) -> Result<OriginalEdits<'_, 'i, 's>, Error> {
Plan::validate_names(self.plan.index, &self.plan.names, self.budget)?;
self.plan.validate(current, self.budget)?;
Ok(OriginalEdits { plan: &self.plan })
}
}
#[derive(Debug)]
pub(super) struct OriginalEdits<'p, 'i, 's> {
plan: &'p Plan<'i, 's>,
}
impl<'s> OriginalEdits<'_, '_, 's> {
pub fn index(&self) -> &Index<'s> {
self.plan.index
}
pub fn len(&self) -> usize {
self.plan.edits.len()
}
pub fn is_empty(&self) -> bool {
self.plan.edits.is_empty()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = OriginalEdit<'_>> {
self.plan.edits.iter().map(|edit| OriginalEdit {
key: &edit.key,
location: edit.location,
original: &self.plan.index.sources()[&edit.location.file]
[edit.location.span.start..edit.location.span.end],
replacement: &self.plan.names[&edit.key.target],
})
}
}
/// A borrowed inspection record, never independent edit authority. Only an
/// intact OriginalEdits batch can carry this producer's complete rename intent.
#[derive(Debug)]
pub(super) struct OriginalEdit<'p> {
key: &'p OccurrenceKey,
location: Location,
original: &'p str,
replacement: &'p str,
}
impl OriginalEdit<'_> {
pub fn key(&self) -> &OccurrenceKey {
self.key
}
pub fn location(&self) -> Location {
self.location
}
pub fn original(&self) -> &str {
self.original
}
pub fn replacement(&self) -> &str {
self.replacement
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,744 @@
use super::super::budget::Limits;
use super::*;
use crate::analysis as a;
use crate::analysis_numeric::Exact;
use crate::language::ParseLimits;
use crate::{Id, Layer, Project};
const DEFINITIONS: &str = "// 雪 @f(a:) $a: comment spellings stay exact\r\n\
calculation f \"f a b\" id \"00000000-0000-0000-0000-000000000100\" {\r\n\
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
result Number; body $a + $b;\r\n}\r\n\
calculation g \"g a b\" id \"00000000-0000-0000-0000-000000000200\" {\r\n\
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
result Number; body $a + $b;\r\n}\r\n";
const CALLER: &str = "calculation caller \"Caller\" id \"00000000-0000-0000-0000-000000000300\" {\n\
result Number; body @f(b: 2, a: 1) // first 雪 @f(a:)\n\
+ @f(a: 3, b: 4) // second\n\
+ @g(a: 5, b: 6);\n}\n";
fn budget() -> Budget {
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn global(id: u128) -> Target {
Target::Global(Id::from_u128(id))
}
fn parameter(calculation: u128, parameter: u128) -> Target {
Target::Parameter {
calculation: Id::from_u128(calculation),
parameter: a::ParameterId(Id::from_u128(parameter)),
}
}
fn sources() -> BTreeMap<usize, String> {
BTreeMap::from([(11, DEFINITIONS.into()), (23, CALLER.into())])
}
fn skeleton() -> Project {
let mut project = super::super::model_oracle::project(Layer::Oa);
project.definitions.clear();
project
.elements
.retain(|_, element| !element.kind().is_analysis());
project
}
fn binary(left: a::Expr, right: a::Expr) -> a::Expr {
a::Expr::Binary {
op: a::BinaryOp::Add,
left: Box::new(left),
right: Box::new(right),
}
}
fn expected(renamed: bool) -> Project {
// Independent model construction: no parsed AST, source index, writer or
// generated edit participates in this expected complete Project.
let mut project = skeleton();
let p = |id| a::ParameterId(Id::from_u128(id));
for (id, alias, name) in [
(0x100, if renamed { "g" } else { "f" }, "f a b"),
(0x200, if renamed { "f" } else { "g" }, "g a b"),
] {
let names = if renamed && id == 0x100 {
["b", "a"]
} else {
["a", "b"]
};
let body = a::CalculationDefinition {
parameters: [(0x200, names[0]), (0x201, names[1])]
.into_iter()
.map(|(id, name)| a::Parameter {
id: p(id),
name: name.into(),
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
})
.collect(),
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(binary(
a::Expr::Parameter(p(0x200)),
a::Expr::Parameter(p(0x201)),
)),
};
let id = Id::from_u128(id);
project.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: a::DefinitionBody::Calculation(body),
},
);
}
let call = |id, x, y| a::Expr::Call {
definition: a::DefinitionRef::Local(Id::from_u128(id)),
arguments: BTreeMap::from([
(p(0x200), a::Expr::Number(Exact::from_integer(x))),
(p(0x201), a::Expr::Number(Exact::from_integer(y))),
]),
};
let id = Id::from_u128(0x300);
project.definitions.insert(
id,
a::Definition {
id,
alias: "caller".into(),
name: "Caller".into(),
description: String::new(),
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(binary(
binary(call(0x100, 1, 2), call(0x100, 3, 4)),
call(0x200, 5, 6),
)),
}),
},
);
project
}
fn merge(mut project: Project, lowered: super::super::lower::Lowered) -> Project {
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
project
}
fn rename_map() -> BTreeMap<Target, String> {
BTreeMap::from([
(global(0x100), "g".into()),
(global(0x200), "f".into()),
(parameter(0x100, 0x200), "b".into()),
(parameter(0x100, 0x201), "a".into()),
])
}
#[test]
fn simultaneous_global_and_scoped_swaps_match_original_token_and_whole_model_oracles() {
let sources = sources();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
assert_eq!(merge(skeleton(), lowered), expected(false));
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
// List expected edits against the original authored bytes independently of
// the index. Full contexts deliberately exclude equal strings/comments.
let mut edits = Vec::new();
let mut add = |file, context: &str, token: &str, replacement: &str| {
let source = &sources[&file];
let context_start = source.find(context).unwrap();
let start = context_start + context.find(token).unwrap();
edits.push((file, start, start + token.len(), replacement.to_owned()));
};
add(11, "calculation f ", "f", "g");
add(11, "calculation g ", "g", "f");
add(11, "input a id", "a", "b");
add(11, "input b id", "b", "a");
add(11, "body $a + $b;", "a", "b");
add(11, "body $a + $b;", "b;", "a;");
// The last oracle edit above includes ';' to disambiguate 'b' in 'body';
// restrict it back to the exact name token for the span oracle.
edits.last_mut().unwrap().2 -= 1;
edits.last_mut().unwrap().3.pop();
for call in ["@f(b: 2, a: 1)", "@f(a: 3, b: 4)"] {
add_call_edits(&sources, &mut edits, call);
}
let start = CALLER.find("@g(a: 5, b: 6)").unwrap() + 1;
edits.push((23, start, start + 1, "f".into()));
edits.sort_by_key(|edit| (edit.0, edit.1));
assert_eq!(plan.edit_count(), 13);
assert_eq!(
plan.edits
.iter()
.map(|edit| (
edit.location.file,
edit.location.span.start,
edit.location.span.end,
plan.names[&edit.key.target].clone()
))
.collect::<Vec<_>>(),
edits
);
let mut exact = BTreeMap::new();
for (&file, source) in &sources {
let mut output = source.clone();
for (_, start, end, value) in edits.iter().filter(|edit| edit.0 == file).rev() {
output.replace_range(*start..*end, value);
}
exact.insert(file, output);
}
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(output, exact);
assert!(output[&11].contains("// 雪 @f(a:) $a: comment spellings stay exact\r\n"));
assert!(output[&23].contains("// first 雪 @f(a:)\n"));
assert!(output[&11].contains("\"f a b\""));
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
let actual = merge(skeleton(), lowered);
assert_eq!(actual, expected(true));
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
}
fn add_call_edits(
sources: &BTreeMap<usize, String>,
edits: &mut Vec<(usize, usize, usize, String)>,
call: &str,
) {
let start = sources[&23].find(call).unwrap();
for (offset, replacement) in [
(1, "g"),
(call.find("a:").unwrap(), "b"),
(call.find("b:").unwrap(), "a"),
] {
edits.push((23, start + offset, start + offset + 1, replacement.into()));
}
}
#[test]
fn alias_edits_preserve_unit_type_override_tokens_and_unrelated_authored_bytes() {
let source = include_str!("../lower/fixtures/typed-oa.arcadia")
.replace("750 [@millisecond]", "750 // exact 雪\r\n [@millisecond]");
let files = BTreeMap::from([(59, source.clone())]);
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &files, &mut work).unwrap();
let mut expected_project = super::super::model_oracle::project(Layer::Oa);
let id = |name: &str| {
index
.names()
.iter()
.find(|(target, alias)| matches!(target, Target::Global(_)) && alias.as_str() == name)
.map(|(target, _)| *target)
.unwrap()
};
let renames = BTreeMap::from([
(id("duration"), "time_kind".into()),
(id("millisecond"), "ms_unit".into()),
(id("receive_time"), "arrival".into()),
]);
let plan = Plan::renames(&index, &renames, &work).unwrap();
let output = plan.apply(&files, &work).unwrap();
let expected_source = source
.replace("quantity_kind duration ", "quantity_kind time_kind ")
.replace("@duration", "@time_kind")
.replace("unit millisecond ", "unit ms_unit ")
.replace("@millisecond", "@ms_unit")
.replace("oa_property receive_time ", "oa_property arrival ")
.replace("@receive_time", "@arrival");
assert_eq!(output[&59], expected_source);
for definition in expected_project.definitions.values_mut() {
if let Some(name) = renames.get(&Target::Global(definition.id)) {
definition.alias = name.clone();
}
}
for element in expected_project.elements.values_mut() {
if let Some(name) = renames.get(&Target::Global(element.id)) {
element.alias = name.clone();
}
}
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
assert_eq!(merge(skeleton(), lowered), expected_project);
}
#[test]
fn rename_rejects_unknown_invalid_and_colliding_final_names_but_allows_noop() {
let sources = sources();
let work = &mut budget();
let (index, _) = Index::parse(&skeleton(), &sources, work).unwrap();
for renames in [
BTreeMap::from([(global(999), "new".into())]),
BTreeMap::from([(global(0x100), "bad name".into())]),
BTreeMap::from([(global(0x100), "g".into())]),
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
] {
assert!(Plan::renames(&index, &renames, work).is_err());
}
let plan = Plan::renames(&index, &BTreeMap::from([(global(0x100), "f".into())]), work).unwrap();
assert_eq!(plan.edit_count(), 0);
assert_eq!(plan.apply(&sources, work).unwrap(), sources);
}
#[test]
fn stale_file_sets_bytes_forged_spans_and_duplicate_edits_fail_before_output() {
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
for damage in 0..6 {
let mut current = sources.clone();
let mut plan = Plan::renames(&index, &rename_map(), &work).unwrap();
match damage {
0 => current
.get_mut(&23)
.unwrap()
.push_str("// unrelated change"),
1 => {
current.remove(&23);
}
2 => {
current.insert(99, String::new());
}
3 => plan.edits[0].location.span.start += 1,
4 => plan.edits.insert(1, plan.edits[0].clone()),
_ => {
plan.edits.pop();
}
}
assert!(plan.apply(&current, &work).is_err(), "damage {damage}");
}
}
#[test]
fn output_and_plan_limits_are_aggregate_and_do_not_reset_parse_work() {
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let usage = work.usage;
let numeric = work.numeric_units();
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
let bytes = output.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = bytes;
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_ok());
limits.bytes -= 1;
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_err());
limits = Limits::source8(ParseLimits::UNLIMITED);
limits.references = 0;
limits.entries = 0;
assert!(Plan::renames(&index, &rename_map(), &Budget::new(limits).unwrap()).is_err());
}
#[test]
fn composition_exports_complete_borrowed_swap_witnesses_and_empty_noops() {
use super::super::index::TokenRole;
use super::super::lower::{Child, Field, Role};
type Key = (Id, Option<Field>, Option<Vec<Child>>, TokenRole, Target);
type Row = (Location, String, String, Key);
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let usage = work.usage;
let numeric = work.numeric_units();
let composition = Composition::renames(&index, &rename_map(), &work).unwrap();
assert!(std::ptr::eq(composition.budget, &work));
// A byte-identical snapshot is valid; records still borrow the actual index.
let batch = composition.original_edits(&sources.clone()).unwrap();
assert!(std::ptr::eq(batch.index(), &index));
assert_eq!(batch.len(), 13);
assert!(!batch.is_empty());
assert_eq!(batch.iter().len(), 13);
// Independently authored occurrence identities and full source contexts.
// No index offset/path or generated output defines this oracle.
let mut expected: Vec<Row> = Vec::new();
let mut add = |file, context: &str, prefix: &str, old: &str, new: &str, key: Key| {
let start = sources[&file].find(context).unwrap() + prefix.len();
assert_eq!(&sources[&file][start..start + old.len()], old);
expected.push((
Location {
file,
span: Span {
start,
end: start + old.len(),
},
},
old.into(),
new.into(),
key,
));
};
for (id, old, new, context) in [
(0x100, "f", "g", "calculation f "),
(0x200, "g", "f", "calculation g "),
] {
add(
11,
context,
"calculation ",
old,
new,
(
Id::from_u128(id),
None,
None,
TokenRole::Declaration,
global(id),
),
);
}
for (id, old, new, context, edge, prefix) in [
(0x200, "a", "b", "input a id", Child::Left, "body $"),
(0x201, "b", "a", "input b id", Child::Right, "body $a + $"),
] {
let target = parameter(0x100, id);
add(
11,
context,
"input ",
old,
new,
(
Id::from_u128(0x100),
Some(Field::Input(a::ParameterId(Id::from_u128(id)))),
None,
TokenRole::Use(Role::ParameterDeclaration),
target,
),
);
add(
11,
"body $a + $b;",
prefix,
old,
new,
(
Id::from_u128(0x100),
Some(Field::Body),
Some(vec![edge]),
TokenRole::Use(Role::ParameterUse),
target,
),
);
}
for (context, path) in [
("@f(b: 2, a: 1)", vec![Child::Left, Child::Left]),
("@f(a: 3, b: 4)", vec![Child::Left, Child::Right]),
] {
add(
23,
context,
"@",
"f",
"g",
(
Id::from_u128(0x300),
Some(Field::Body),
Some(path.clone()),
TokenRole::Use(Role::CallTarget),
global(0x100),
),
);
for (id, old, new) in [(0x200, "a", "b"), (0x201, "b", "a")] {
let offset = context.find(&format!("{old}:")).unwrap();
add(
23,
context,
&context[..offset],
old,
new,
(
Id::from_u128(0x300),
Some(Field::Body),
Some(path.clone()),
TokenRole::Use(Role::ArgumentLabel),
parameter(0x100, id),
),
);
}
}
add(
23,
"@g(a: 5, b: 6)",
"@",
"g",
"f",
(
Id::from_u128(0x300),
Some(Field::Body),
Some(vec![Child::Right]),
TokenRole::Use(Role::CallTarget),
global(0x200),
),
);
expected.sort_by_key(|row| (row.0.file, row.0.span.start, row.0.span.end));
let actual: Vec<Row> = batch
.iter()
.map(|edit| {
let key = edit.key();
(
edit.location(),
edit.original().into(),
edit.replacement().into(),
(
key.owner,
key.field,
key.path.as_ref().map(|path| path.edges()),
key.role,
key.target,
),
)
})
.collect();
assert_eq!(actual, expected);
let noop = Composition::renames(
&index,
&BTreeMap::from([
(global(0x100), "f".into()),
(parameter(0x200, 0x200), "a".into()),
]),
&work,
)
.unwrap();
let empty = noop.original_edits(&sources).unwrap();
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
assert_eq!(empty.iter().count(), 0);
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
}
#[test]
fn composition_rejects_stale_or_damaged_complete_batches_before_export() {
let mut sources = sources();
sources.insert(99, "// unrelated 雪\r\n".into());
let original = sources.clone();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
for damage in 0..10 {
let mut current = sources.clone();
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
match damage {
0 => current.get_mut(&99).unwrap().push_str("changed"),
1 => {
current.remove(&23);
}
2 => {
current.insert(101, String::new());
}
3 => composition.plan.edits[0].location.span.start += 1,
4 => composition
.plan
.edits
.insert(1, composition.plan.edits[0].clone()),
5 => {
composition.plan.edits.pop();
}
6 => composition.plan.edits.swap(0, 1),
7 => {
composition
.plan
.names
.insert(global(0x100), "bad name".into());
}
8 => {
composition.plan.names.insert(global(0x100), "f".into());
}
_ => {
composition.plan.names.insert(global(999), "missing".into());
}
}
let before = current.clone();
assert!(
composition.original_edits(&current).is_err(),
"damage {damage}"
);
assert_eq!(current, before);
}
assert_eq!(sources, original);
let failure = work.frame(257, Span::default()).unwrap_err();
assert_eq!(
Composition::renames(&index, &rename_map(), &work).unwrap_err(),
failure
);
}
#[test]
fn composition_defers_only_typed_output_size_with_independent_shrink_arithmetic() {
let sources = sources();
let cap = sources.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = cap;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
assert_eq!(work.usage.bytes, cap);
let usage = work.usage;
let numeric = work.numeric_units();
let names = BTreeMap::from([(global(0x100), "ff".into())]);
assert_eq!(
Plan::renames(&index, &names, &work).unwrap_err().message,
"Source8 index/output byte limit exceeded."
);
let composition = Composition::renames(&index, &names, &work).unwrap();
let batch = composition.original_edits(&sources).unwrap();
assert_eq!(batch.len(), 3); // declaration and two distinct call targets
let growth: usize = batch
.iter()
.map(|edit| edit.replacement().len() - edit.original().len())
.sum();
assert_eq!(growth, 3);
assert_eq!(
composition.plan.apply(&sources, &work).unwrap_err().message,
"Source8 index/output byte limit exceeded."
);
// Independent future ordinary/scalar producer witness on untouched display
// name bytes. This is size/offset arithmetic only: this typed-only harness
// has no ordinary collector, mixed ownership proof or accepted output.
let start = DEFINITIONS.find("\"f a b\"").unwrap() + 1;
let shrink = Location {
file: 11,
span: Span {
start,
end: start + "f a b".len(),
},
};
assert_eq!(
&sources[&shrink.file][shrink.span.start..shrink.span.end],
"f a b"
);
for edit in batch.iter() {
let at = edit.location();
assert!(
at.file != shrink.file
|| at.span.end <= shrink.span.start
|| shrink.span.end <= at.span.start
);
}
let original_len = shrink.span.end - shrink.span.start;
assert_eq!(cap + growth - original_len + "fa".len(), cap);
assert_eq!(cap + growth - original_len + "fab".len(), cap + 1);
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
}
#[test]
fn composition_keeps_name_storage_and_rename_record_limits_before_allocation() {
let sources = sources();
let cap = sources.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = cap;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let exact = BTreeMap::from([(global(0x100), "x".repeat(cap))]);
assert_eq!(
Composition::renames(&index, &exact, &work)
.unwrap()
.original_edits(&sources)
.unwrap()
.len(),
3
);
let over = BTreeMap::from([(global(0x100), "x".repeat(cap + 1))]);
assert_eq!(
Composition::renames(&index, &over, &work)
.unwrap_err()
.message,
"Source8 index/output byte limit exceeded."
);
for invalid in [
BTreeMap::from([(global(999), "unknown".into())]),
BTreeMap::from([(global(0x100), "bad name".into())]),
BTreeMap::from([(global(0x100), "g".into())]),
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
] {
assert!(Composition::renames(&index, &invalid, &work).is_err());
}
// A typed-empty fixture isolates the rename-map record bound without
// syntax entry usage masking it. No ordinary edits are authorized here.
let ordinary = skeleton();
let count = ordinary.identities().len();
let files = BTreeMap::from([(1, "// empty typed unit\n".into())]);
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = count;
limits.references = 0;
let mut inherited = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&ordinary, &files, &mut inherited).unwrap();
let mut names: BTreeMap<_, _> = index
.names()
.keys()
.enumerate()
.map(|(i, target)| (*target, format!("renamed_{i}")))
.collect();
assert_eq!(names.len(), count);
assert!(
Composition::renames(&index, &names, &inherited)
.unwrap()
.original_edits(&files)
.unwrap()
.is_empty()
);
names.insert(global(999), "additional".into());
assert_eq!(
Composition::renames(&index, &names, &inherited)
.unwrap_err()
.message,
"Source8 lowering entry limit exceeded."
);
}
#[test]
fn composition_checks_edit_record_ceiling_before_duplicate_membership() {
let sources = sources();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = 128;
limits.references = 32;
let ceiling = limits.entries + limits.references;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
assert_eq!(composition.original_edits(&sources).unwrap().len(), 13);
let duplicate = composition.plan.edits[0].clone();
composition.plan.edits = vec![duplicate.clone(); ceiling];
// At the exact count bound validation proceeds to the independent ownership
// error. The damaged plan never becomes a successful/authoritative batch.
assert_eq!(
composition.original_edits(&sources).unwrap_err().message,
"Forged or duplicate edit occurrence."
);
composition.plan.edits.push(duplicate);
assert_eq!(
composition.original_edits(&sources).unwrap_err().message,
"Source8 lowering occurrence limit exceeded."
);
}
#[test]
fn standalone_noop_apply_keeps_tighter_file_only_budget_and_output_boundary() {
let sources = sources();
let mut inherited = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut inherited).unwrap();
let plan = Plan::renames(
&index,
&BTreeMap::from([(global(0x100), "f".into())]),
&inherited,
)
.unwrap();
assert_eq!(plan.edit_count(), 0);
assert!(index.names().len() > sources.len());
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = sources.len();
limits.references = 0;
limits.bytes = sources.values().map(String::len).sum();
// Standalone apply checks file/edit records, not the already validated
// catalog/name-storage allocation from construction. Rechecking the catalog
// under this intentionally tighter probe budget would reject this no-op.
assert_eq!(
plan.apply(&sources, &Budget::new(limits).unwrap()).unwrap(),
sources
);
limits.bytes -= 1;
assert_eq!(
plan.apply(&sources, &Budget::new(limits).unwrap())
.unwrap_err()
.message,
"Source8 index/output byte limit exceeded."
);
}
@@ -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<Id>,
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<u8>,
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<usize> {
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<Id, BTreeMap<a::ParameterId, &'p a::Parameter>>,
limits: Limits,
entries: usize,
sink: Sink,
owner: Option<Id>,
field: &'static str,
failed: Option<Error>,
}
impl<'p> Writer<'p> {
pub fn new(project: &'p Project, limits: Limits) -> Result<Self, Error> {
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(&parameter.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<String>) -> 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<String, Error> {
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<a::AnalysisSubject>) -> 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<String, Error> {
self.run(owner, "type", |writer| writer.type_value(value))
}
fn model_binding(
&self,
owner: Id,
field: Field,
) -> Result<(&'p a::Binding, Option<Id>), 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<Id>,
) -> 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<String, Error> {
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<String, Error> {
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(&parameter.name)?;
self.text(" id \"")?;
self.text(&parameter.id.0.to_string())?;
self.text("\" : ")?;
self.type_value(&parameter.value_type)?;
self.text(";")
}
pub fn parameter(
&mut self,
calculation: Id,
parameter: a::ParameterId,
) -> Result<String, Error> {
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<String, Error> {
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<String, Error> {
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<String, Error> {
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<Vec<(Id, String)>, 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;
@@ -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<Task<'a>>,
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<Id>,
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(&parameter.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(&parameter.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[&parameter.id],
Parent::Root,
depth + 1,
));
tasks.push(Text(": "));
tasks.push(Alias(&parameter.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(())
}
}
@@ -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<lower::Unit, super::super::Error> {
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(&degraded)
.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(&quoted, "second");
quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into();
let text = writer(&quoted).definition(unit_id).unwrap();
for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] {
assert_eq!(
Writer::new(
&quoted,
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);
}
+347
View File
@@ -0,0 +1,347 @@
//! One raw lexical scan for legacy adapters and staged typed source syntax.
//! Numbers remain source spans; only consumers choose a numeric domain.
use super::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Class {
Word,
Number,
String,
Punctuation,
Whitespace,
Comment,
Error,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Token {
pub class: Class,
pub span: Span,
/// Entire token belongs to the old greedy numeric-token alphabet.
pub legacy_number_part: bool,
}
pub(super) struct Scanner<'a> {
source: &'a str,
cursor: usize,
}
impl<'a> Scanner<'a> {
pub fn new(source: &'a str) -> Self {
Self { source, cursor: 0 }
}
}
impl Iterator for Scanner<'_> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
let source = self.source;
let bytes = source.as_bytes();
let start = self.cursor;
let first = *bytes.get(start)?;
let mut legacy_number_part = false;
let class = if matches!(first, b' ' | b'\t' | b'\r' | b'\n') {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
{
self.cursor += 1;
}
Class::Whitespace
} else if bytes[start..].starts_with(b"//") {
self.cursor += 2;
while bytes.get(self.cursor).is_some_and(|b| *b != b'\n') {
self.cursor += 1;
}
Class::Comment
} else if first.is_ascii_alphabetic() || first == b'_' {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
self.cursor += 1;
}
legacy_number_part = true;
Class::Word
} else if first.is_ascii_digit() {
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
let mut valid = true;
if bytes.get(self.cursor) == Some(&b'.') {
self.cursor += 1;
let fraction = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != fraction;
}
if matches!(bytes.get(self.cursor), Some(b'e' | b'E')) {
self.cursor += 1;
if matches!(bytes.get(self.cursor), Some(b'+' | b'-')) {
self.cursor += 1;
}
let exponent = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != exponent;
}
if bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'.'))
{
valid = false;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.'))
{
self.cursor += 1;
}
}
legacy_number_part = true;
if valid { Class::Number } else { Class::Error }
} else if first == b'"' {
self.cursor += 1;
let mut escaped = false;
let mut closed = false;
while self.cursor < bytes.len() {
let ch = source[self.cursor..]
.chars()
.next()
.expect("UTF-8 boundary");
self.cursor += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..self.cursor]).is_ok() {
Class::String
} else {
Class::Error
}
} else if b"{}();,[]:=@$+-*/<>!".contains(&first) {
self.cursor += 1;
if matches!(first, b'<' | b'>' | b'=' | b'!') && bytes.get(self.cursor) == Some(&b'=') {
self.cursor += 1;
}
legacy_number_part = matches!(first, b'+' | b'-');
Class::Punctuation
} else {
self.cursor += source[start..]
.chars()
.next()
.expect("UTF-8 boundary")
.len_utf8();
legacy_number_part = first == b'.';
Class::Error
};
Some(Token {
class,
span: Span {
start,
end: self.cursor,
},
legacy_number_part,
})
}
}
/// Preserve the existing public token shapes, including greedy signed JSON
/// numbers and single-character rejection of newer expression operators.
pub(super) fn legacy(source: &str) -> Vec<super::TokenSpan> {
use super::{TokenClass, TokenSpan};
let mut scanner = Scanner::new(source).peekable();
let mut out = Vec::new();
while let Some(token) = scanner.next() {
let mut span = token.span;
let first = source.as_bytes()[span.start];
if first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.') {
while scanner
.peek()
.is_some_and(|next| next.legacy_number_part && next.span.start == span.end)
{
span.end = scanner.next().expect("peeked token").span.end;
}
let class = if serde_json::from_str::<serde_json::Number>(&source[span.start..span.end])
.is_ok()
{
TokenClass::Number
} else {
TokenClass::Error
};
out.push(TokenSpan { class, span });
continue;
}
let class = match token.class {
Class::Word => {
let word = &source[span.start..span.end];
if super::reserved(word) || matches!(word, "true" | "false" | "none") {
TokenClass::Keyword
} else {
TokenClass::Identifier
}
}
Class::String => TokenClass::String,
Class::Whitespace => TokenClass::Whitespace,
Class::Comment => TokenClass::Comment,
Class::Punctuation => {
// The raw scanner joins comparison operators. Legacy syntax
// classified their ASCII characters individually.
for at in span.start..span.end {
let class = if b"{}();,[]:=@".contains(&source.as_bytes()[at]) {
TokenClass::Punctuation
} else {
TokenClass::Error
};
out.push(TokenSpan {
class,
span: Span {
start: at,
end: at + 1,
},
});
}
continue;
}
Class::Error => TokenClass::Error,
Class::Number => unreachable!("number tokens start with a digit"),
};
out.push(TokenSpan { class, span });
}
out
}
#[cfg(test)]
mod tests {
use super::super::{TokenClass, tokenize};
#[test]
fn legacy_adapter_retains_authored_numeric_operator_and_trivia_tokens() {
use TokenClass::*;
let cases: &[(&str, &[(TokenClass, &str)])] = &[
(
"-1.5 1e-2 -0",
&[
(Number, "-1.5"),
(Whitespace, " "),
(Number, "1e-2"),
(Whitespace, " "),
(Number, "-0"),
],
),
(
"1-2 1 -2 1 - 2",
&[
(Error, "1-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Number, "-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Error, "-"),
(Whitespace, " "),
(Number, "2"),
],
),
(
"+1 .5 1_0 0x1 1e 1e+",
&[
(Error, "+1"),
(Whitespace, " "),
(Error, ".5"),
(Whitespace, " "),
(Error, "1_0"),
(Whitespace, " "),
(Error, "0x1"),
(Whitespace, " "),
(Error, "1e"),
(Whitespace, " "),
(Error, "1e+"),
],
),
(
"<= != == >= / * $ !",
&[
(Error, "<"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "!"),
(Punctuation, "="),
(Whitespace, " "),
(Punctuation, "="),
(Punctuation, "="),
(Whitespace, " "),
(Error, ">"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "/"),
(Whitespace, " "),
(Error, "*"),
(Whitespace, " "),
(Error, "$"),
(Whitespace, " "),
(Error, "!"),
],
),
(
"// π\r\n\"雪\" @alias",
&[
(Comment, "// π\r"),
(Whitespace, "\n"),
(String, "\"雪\""),
(Whitespace, " "),
(Punctuation, "@"),
(Keyword, "alias"),
],
),
("\"incomplete 雪", &[(Error, "\"incomplete 雪")]),
("\"bad\\q\"", &[(Error, "\"bad\\q\"")]),
(
"雪\u{b}a",
&[(Error, "雪"), (Error, "\u{b}"), (Identifier, "a")],
),
(
"unit calculation quantity_kind if and List Number",
&[
(Identifier, "unit"),
(Whitespace, " "),
(Identifier, "calculation"),
(Whitespace, " "),
(Identifier, "quantity_kind"),
(Whitespace, " "),
(Identifier, "if"),
(Whitespace, " "),
(Identifier, "and"),
(Whitespace, " "),
(Identifier, "List"),
(Whitespace, " "),
(Identifier, "Number"),
],
),
];
for (source, expected) in cases {
let actual = tokenize(source);
assert_eq!(actual.len(), expected.len(), "{source:?}");
let mut offset = 0;
for (token, (class, spelling)) in actual.iter().zip(*expected) {
assert_eq!(token.class, *class, "{source:?} at {offset}");
assert_eq!(token.span.start, offset);
offset += spelling.len();
assert_eq!(token.span.end, offset);
assert_eq!(&source[token.span.start..token.span.end], *spelling);
}
assert_eq!(offset, source.len());
}
}
}
@@ -194,6 +194,8 @@ pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
#[cfg(test)]
mixed: None,
};
let ast = parser.value().ok()?;
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
@@ -0,0 +1,52 @@
//! Convert only source3 lowering scratch state into its frozen migration input.
//! This is not a current-to-legacy exporter: fields outside that scratch state
//! must reject instead of being silently discarded.
use crate::Project;
use crate::persistence::{FileError, v3};
use serde_json::{Map, Value};
pub(super) fn from_scratch(
project: Project,
legacy_elements: Map<String, Value>,
) -> Result<v3::Project3, FileError> {
// No `..`: a future Project field requires an explicit compatibility decision.
let Project {
schema_version,
id,
alias,
name,
description,
definitions,
elements,
relations,
diagrams,
artifact_links,
traces,
} = project;
if schema_version != 3 {
return Err(FileError::Invalid(
"Expected source3 lowering state.".into(),
));
}
if !definitions.is_empty() || !elements.is_empty() {
return Err(FileError::Invalid(
"Source3 lowering state cannot contain current definitions or elements.".into(),
));
}
Ok(v3::Project3 {
schema_version,
id,
alias,
name,
description,
elements: serde_json::from_value(Value::Object(legacy_elements))?,
// Keep the frozen nested contracts, including strict trace baselines.
relations: serde_json::from_value(serde_json::to_value(relations)?)?,
diagrams,
artifact_links,
traces: serde_json::from_value(serde_json::to_value(traces)?)?,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,188 @@
use super::*;
use crate::{Id, language, persistence};
use serde_json::json;
const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia");
const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json");
const PROSE: &str = r#"arcadia 3;
project review "Review" id "00000000-0000-0000-0000-000000000001" {
oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {}
sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {}
trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" {
oa @reviewed_activity; sa @function; kind Refinement;
baseline {
oa { name "Approved OA"; description "First\nΩ rationale"; };
sa { name "Approved SA"; description "Prior SA prose"; };
};
}
}"#;
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn prose_json() -> Value {
json!({
"schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"",
"elements": {
id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}},
id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}}
},
"relations":{},"diagrams":{},"artifact_links":{},
"traces": {id(4).to_string():{
"id":id(4),"alias":"review_link","name":"Review link","description":"",
"oa":id(2),"sa":id(3),"kind":"Refinement",
"baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}}
}}
})
}
#[test]
fn frozen_source3_and_json3_have_complete_model_and_migration_parity() {
let source = language::parse_with_report(SOURCE3).unwrap();
let json = persistence::decode_with_report(JSON3).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
assert!(source.project.definitions.is_empty());
let report = source.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(3, crate::SCHEMA_VERSION)
);
// This tests direct legacy lowering. Canonical source8 reopening remains a
// separate, unchanged public integration gate.
}
#[test]
fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() {
let original: Value = serde_json::from_slice(JSON3).unwrap();
for definitions in [
json!({}),
Value::Null,
json!({id(90).to_string(): {"name":"new"}}),
] {
let mut bad = original.clone();
bad["definitions"] = definitions;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("unknown field `definitions`"), "{error}");
}
let mut bad = original;
bad["unrecognized"] = json!(true);
assert!(
persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string()
.contains("unknown field `unrecognized`")
);
let raw = std::str::from_utf8(JSON3).unwrap();
let duplicate = raw.replacen(
"\"schema_version\": 3",
"\"schema_version\": 3, \"schema_version\": 3",
1,
);
assert_ne!(duplicate, raw);
assert!(
persistence::decode(duplicate.as_bytes())
.err()
.unwrap()
.to_string()
.contains("duplicate")
);
}
#[test]
fn old_review_prose_survives_without_admitting_semantic_fields() {
let source = language::parse_with_report(PROSE).unwrap();
let json =
persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap();
assert_eq!(baseline.oa.name, "Approved OA");
assert_eq!(baseline.oa.description, "First\nΩ rationale");
assert_eq!(baseline.sa.description, "Prior SA prose");
assert!(baseline.semantic.is_none());
for semantic in [Value::Null, json!({"facets":[]})] {
let mut bad = prose_json();
bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
for field in ["semantic none;", "semantic { facets []; };"] {
let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}"));
let errors = language::parse(&bad).err().unwrap();
assert!(
errors
.iter()
.any(|e| e.message.contains("require schema/source5")),
"{errors:?}"
);
}
}
#[test]
fn scratch_bridge_refuses_to_discard_current_only_data() {
let mut empty = Project::empty();
empty.schema_version = 3;
assert!(from_scratch(empty.clone(), Map::new()).is_ok());
let mut wrong_version = empty.clone();
wrong_version.schema_version = crate::SCHEMA_VERSION;
assert!(from_scratch(wrong_version, Map::new()).is_err());
let mut definition = empty;
definition.definitions.insert(
id(90),
crate::analysis::Definition {
id: id(90),
alias: "dimensionless".into(),
name: "Dimensionless".into(),
description: String::new(),
body: crate::analysis::DefinitionBody::QuantityKind(
crate::analysis::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: crate::analysis::QuantityAlgebra::Linear,
},
),
},
);
assert!(
from_scratch(definition, Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap())
.unwrap()
.project;
current.schema_version = 3;
assert!(
from_scratch(current.clone(), Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
current.elements.clear();
current
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() });
assert!(
from_scratch(current, Map::new())
.err()
.unwrap()
.to_string()
.contains("semantic")
);
}
@@ -1,386 +0,0 @@
//! Prepared outline tests. Explicit compatibility tests additionally exercise
//! the existing parsers/analyze; the outline entry itself never calls them.
use super::*;
fn limits() -> OutlineLimits {
OutlineLimits {
max_source_bytes: 1024 * 1024,
max_tokens: 100_000,
max_declarations: 1_000,
}
}
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
result
.declarations
.iter()
.find(|d| d.declaration.alias == alias)
.unwrap()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
let before = source.as_bytes().to_vec();
let result = outline(source, limits()).unwrap();
assert_eq!(result.source.as_bytes(), before);
assert_eq!(result.source.as_ptr(), source.as_ptr());
let d = declaration(&result, "responder");
assert_eq!(text(source, d.kind_span), "oa_actor");
assert_eq!(text(source, d.alias_span), "responder");
assert_eq!(
text(source, d.id_literal_span.unwrap()),
"\"\\u00300000000-0000-0000-0000-000000000001\""
);
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
let mut end = 0;
for token in &result.tokens {
assert_eq!(token.span.start, end);
assert!(token.span.start < token.span.end);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
}
#[test]
fn closed_id_at_eof_is_inside_the_declaration_extent() {
for source in [
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
"node placed id \"00000000-0000-0000-0000-000000000001\"",
] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
let literal = d.id_literal_span.unwrap();
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.end, source.len());
assert!(d.declaration.span.start <= literal.start);
assert!(literal.end <= d.declaration.span.end);
}
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
assert_eq!(d.declaration.span.end, source.len());
assert_eq!(d.declaration.id, None);
assert_eq!(d.id_literal_span, None);
}
}
#[test]
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
let first = outline(source, limits()).unwrap();
let second = outline(source, limits()).unwrap();
assert_eq!(first, second);
assert!(
first
.declarations
.iter()
.all(|d| d.declaration.id.is_none())
);
assert_eq!(declaration(&first, "missing").id_literal_span, None);
assert_eq!(declaration(&first, "historical").id_literal_span, None);
assert_eq!(
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
"\"not-a-uuid\""
);
// Balanced tokens are not a successful syntax/semantic validation claim.
assert!(!first.has_lexical_errors);
assert!(!first.has_unmatched_braces);
}
#[test]
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
let result = outline(source, limits()).unwrap();
assert_eq!(result.source, source);
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
assert!(result.has_lexical_errors);
assert!(result.has_unmatched_braces);
let ready = declaration(&result, "ready");
assert_eq!(
text(source, ready.declaration.span),
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
);
assert_eq!(
declaration(&result, "outer").declaration.span.end,
source.len()
);
assert_eq!(
declaration(&result, "pending").declaration.span.end,
source.len()
);
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
assert!(unmatched_close.has_unmatched_braces);
assert!(!unmatched_close.has_lexical_errors);
}
#[test]
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 1);
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
// There is deliberately no `valid` flag: the known-complete fact is scanning
// these bytes, not the missing body becoming legal or the draft being applied.
assert_eq!(result.source, source);
}
#[test]
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 5);
let duplicates: Vec<_> = result
.declarations
.iter()
.filter(|d| d.declaration.alias == "oa_activity")
.collect();
assert_eq!(duplicates.len(), 2);
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
assert!(
result
.tokens
.iter()
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
);
assert_eq!(
text(source, declaration(&result, "placement").kind_span),
"node"
);
assert_eq!(declaration(&result, "placement").declaration.id, None);
}
#[test]
fn bounds_refuse_before_returning_a_partial_outline() {
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
let admitted = outline(source, limits()).unwrap();
let exact = OutlineLimits {
max_source_bytes: source.len(),
max_tokens: admitted.tokens.len(),
max_declarations: 2,
};
assert_eq!(outline(source, exact).unwrap(), admitted);
for resource in [
OutlineResource::SourceBytes,
OutlineResource::Tokens,
OutlineResource::Declarations,
] {
let mut lower = exact;
match resource {
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
OutlineResource::Tokens => lower.max_tokens -= 1,
OutlineResource::Declarations => lower.max_declarations -= 1,
}
let error = outline(source, lower).unwrap_err();
assert_eq!(error.resource, resource);
assert!(error.observed_at_least > error.limit);
assert!(source.is_char_boundary(error.offset));
if resource == OutlineResource::Declarations {
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
}
}
let empty = outline(
"",
OutlineLimits {
max_source_bytes: 0,
max_tokens: 0,
max_declarations: 0,
},
)
.unwrap();
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
let error = outline(
unicode,
OutlineLimits {
max_source_bytes: 4,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::SourceBytes);
assert_eq!(error.offset, 0);
let error = outline(
unicode,
OutlineLimits {
max_tokens: 1,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Tokens);
assert_eq!(error.offset, unicode.find('\n').unwrap());
}
#[test]
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
let old = "oa_actor a \"A\" {}\nsource @a;";
let current = format!("// 航海 🚒\r\n{old}");
let old_outline = outline(old, limits()).unwrap();
let current_outline = outline(&current, limits()).unwrap();
let shift = current.len() - old.len();
let old_role = &old_outline.declarations[0];
let current_role = &current_outline.declarations[0];
assert_eq!(
current_role.alias_span.start,
old_role.alias_span.start + shift
);
assert_eq!(
current_role.kind_span.start,
old_role.kind_span.start + shift
);
assert_eq!(
current_role.declaration.span.end,
old_role.declaration.span.end + shift
);
assert_eq!(current_outline.source, current);
assert_eq!(old_outline.source, old);
assert_eq!(current_role.declaration.id, None);
assert!(!current_outline.source.contains(" id "));
// Workspace revision/stale-handle rejection belongs to the query/adapter,
// not a claim that an old owned span can be applied to any other string.
}
#[test]
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
let mut source = String::new();
for index in 0..600 {
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
}
source.push_str(&"}\n".repeat(600));
let result = outline(&source, limits()).unwrap();
assert_eq!(result.declarations.len(), 600);
assert!(!result.has_unmatched_braces);
assert_eq!(
result.declarations[0].declaration.span.end,
source.len() - 1
);
let error = outline(
&source,
OutlineLimits {
max_declarations: 599,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Declarations);
assert_eq!(error.observed_at_least, 600);
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
}
#[test]
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
assert!(
Id::parse_str(raw).is_ok(),
"The UUID crate alone is too permissive"
);
assert!(!has_uuid_header_shape(raw));
let literal = format!("\"{raw}\"");
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let before = modern.clone();
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(modern, before);
let errors = parse(&modern).unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12")
&& error.span.start == modern.find(&literal).unwrap())
);
// Intentional compatibility correction: old analyze yielded Some(Id(1))
// here despite lint rejecting the header. It now shares outline's None.
let analysis = analyze(&modern);
assert_eq!(
analysis
.declarations
.iter()
.find(|d| d.alias == "model")
.unwrap()
.id,
None
);
assert!(
analysis
.diagnostics
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
assert!(
legacy_errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
}
}
#[test]
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
let expected = Id::from_u128(0xabcdef);
let canonical = expected.to_string();
let uppercase = canonical.to_uppercase();
assert_ne!(uppercase, canonical);
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let decoded: String = serde_json::from_str(&literal).unwrap();
assert!(has_uuid_header_shape(&decoded));
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, Some(expected));
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(result.source.as_bytes(), modern.as_bytes());
assert_eq!(parse(&modern).unwrap().id, expected);
let analysis = analyze(&modern);
assert_eq!(analysis.declarations[0].id, Some(expected));
assert!(
!analysis
.diagnostics
.iter()
.any(|error| error.severity == crate::validation::Severity::Error)
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
}
}
#[test]
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
let raw = "00000000-0000-0000-0000-00000000000Z";
assert!(has_uuid_header_shape(raw));
assert!(Id::parse_str(raw).is_err());
let literal = format!("\"{raw}\"");
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&source, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
assert!(
parse(&source)
.unwrap_err()
.iter()
.any(|error| error.message == "Invalid UUID identity.")
);
assert_eq!(analyze(&source).declarations[0].id, None);
}
+2
View File
@@ -1,4 +1,6 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis;
pub mod analysis_numeric;
pub mod data;
pub mod language;
pub mod model;
+1
View File
@@ -75,6 +75,7 @@ impl Project4 {
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
+62
View File
@@ -0,0 +1,62 @@
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
//! Unchanged nested records are shared only after the closed old-kind check.
use crate::persistence::FileError;
use crate::*;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project7 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
}
impl Project7 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if !(5..=7).contains(&self.schema_version) {
return Err(FileError::Invalid(
"Expected frozen schema5, 6 or 7.".into(),
));
}
reject_elements(self.elements.values())?;
Ok(Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self.traces,
})
}
}
pub(crate) fn reject_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if element.kind().is_analysis() {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 8.",
element.kind()
)));
}
}
Ok(())
}
+43 -3
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 7;
pub const SCHEMA_VERSION: u32 = 8;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -64,9 +64,12 @@ pub enum Kind {
Terminate,
Event,
Constraint,
EngineeringProperty,
AnalysisCase,
AnalysisCheck,
}
impl Kind {
pub const ALL: [Self; 39] = [
pub const ALL: [Self; 42] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -106,6 +109,9 @@ impl Kind {
Self::Terminate,
Self::Event,
Self::Constraint,
Self::EngineeringProperty,
Self::AnalysisCase,
Self::AnalysisCheck,
];
pub fn label(self) -> &'static str {
match self {
@@ -148,6 +154,9 @@ impl Kind {
Self::Terminate => "Terminate",
Self::Event => "Event",
Self::Constraint => "Constraint",
Self::EngineeringProperty => "Engineering property",
Self::AnalysisCase => "Analysis case",
Self::AnalysisCheck => "Analysis check",
}
}
pub fn is_performer(self) -> bool {
@@ -156,6 +165,9 @@ impl Kind {
Self::Entity | Self::Actor | Self::System | Self::SystemActor
)
}
pub fn is_analysis(self) -> bool {
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
}
pub fn is_work(self) -> bool {
matches!(self, Self::Activity | Self::Function)
}
@@ -652,6 +664,23 @@ pub enum SharedBody {
expression: String,
constrained: Vec<Id>,
},
EngineeringProperty {
subject: crate::analysis::AnalysisSubject,
value_type: crate::analysis::AnalysisType,
display_unit: Option<crate::analysis::DefinitionRef>,
base: crate::analysis::Binding,
},
AnalysisCase {
subject: Option<crate::analysis::AnalysisSubject>,
base: crate::analysis::BaselineBase,
#[serde(deserialize_with = "unique_id_map")]
overrides: BTreeMap<Id, crate::analysis::Binding>,
},
AnalysisCheck {
subject: Option<crate::analysis::AnalysisSubject>,
documented_constraint: Option<Id>,
predicate: crate::analysis::Binding,
},
}
impl Element {
pub fn layer(&self) -> Layer {
@@ -752,7 +781,8 @@ impl ElementBody {
}
}
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
if !kind.supports(layer) {
// Analysis creation requires explicitly authored types and bindings.
if !kind.supports(layer) || kind.is_analysis() {
return None;
}
Some(match kind {
@@ -864,6 +894,9 @@ impl SharedBody {
Self::Region => Kind::Region,
Self::State { kind, .. } => kind.kind(),
Self::Constraint { .. } => Kind::Constraint,
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
Self::AnalysisCase { .. } => Kind::AnalysisCase,
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
}
}
}
@@ -1625,6 +1658,8 @@ pub struct Project {
pub name: String,
pub description: String,
#[serde(deserialize_with = "unique_id_map")]
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
#[serde(deserialize_with = "unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
@@ -1717,6 +1752,7 @@ impl Project {
alias: "mission".into(),
name: "Untitled mission".into(),
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1726,6 +1762,7 @@ impl Project {
}
pub fn identities(&self) -> Vec<(Id, &str)> {
let mut out = vec![(self.id, self.alias.as_str())];
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
@@ -1783,6 +1820,9 @@ impl Project {
if id == self.id {
return &self.name;
}
if let Some(v) = self.definitions.get(&id) {
return &v.name;
}
if let Some(v) = self.elements.get(&id) {
return &v.name;
}
+209 -97
View File
@@ -2,6 +2,10 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
mod json_codec;
#[cfg(test)]
#[path = "persistence/source_save_tests.rs"]
mod source_save_tests;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
@@ -9,6 +13,8 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[path = "migration_v7.rs"]
pub(crate) mod v7;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -130,6 +136,19 @@ pub fn decode_limited(
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
decode_with_report_profile(
bytes,
limits,
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
json_codec::DEPTH_128_VALIDATED,
)
}
fn decode_with_report_profile(
bytes: &[u8],
limits: crate::language::ParseLimits,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
@@ -138,65 +157,43 @@ pub fn decode_with_report_limited(
)));
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
json_codec::preflight(bytes, profile)?;
let version = json_codec::version(bytes)?;
if version.is_some_and(|version| version <= 6) {
let header = json_codec::legacy(bytes)?;
let result = check_legacy_header(&header, version);
json_codec::discard_value(header);
result?;
}
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
{
match version {
Some(1) => {
let old = serde_json::from_slice(bytes)?;
let old = json_codec::legacy(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
Some(7) => DecodeResult {
project: serde_json::from_slice(bytes)?,
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
Some(5) => migrate_v5(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(6) => migrate_v6(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(7) => migrate_v7(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(8) => DecodeResult {
project: json_codec::current(bytes, profile, depth_128)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
.into(),
));
}
}
@@ -225,6 +222,38 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
reject_source7_fields(header).map_err(FileError::Invalid)?;
if version.is_some_and(|version| version <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
Ok(())
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
@@ -348,6 +377,7 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
reject_pre8_project(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -368,48 +398,49 @@ pub(crate) fn migrate_v5(
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str)
== Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null."
.into(),
);
}
pending.extend(fields.values().rev());
}
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
serde_json::Value::Array(values) => {
pending.extend(values.iter().rev());
}
_ => {}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
@@ -420,6 +451,7 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
reject_pre8_project(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
@@ -429,6 +461,30 @@ pub(crate) fn migrate_v6(
})
}
pub(crate) fn migrate_v7(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 7 {
return Err(FileError::Invalid("Expected schema_version 7.".into()));
}
reject_pre8_project(&project)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(7)),
})
}
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
if !project.definitions.is_empty() {
return Err(FileError::Invalid(
"Definitions require schema/source 8.".into(),
));
}
v7::reject_elements(project.elements.values())
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
@@ -846,6 +902,9 @@ pub(crate) mod v1 {
}
fn validate_project(p: &Project) -> Result<(), FileError> {
json_codec::document_stack(|| validate_project_inner(p))
}
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
if let Some(i) = validation::validate(p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
@@ -861,7 +920,10 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&std::fs::read(path)?)
decode_with_report(&json_codec::read_bounded(
path,
json_codec::MAX_DOCUMENT_BYTES,
)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
@@ -874,19 +936,69 @@ pub fn save_checked(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
) -> Result<Vec<u8>, FileError> {
save_checked_profile(
path,
p,
expected,
json_codec::Profile::DOCUMENT,
json_codec::DEPTH_128_VALIDATED,
)
}
fn save_checked_profile(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
crate::language::format(project).into_bytes()
})
}
// Keep the source writer replaceable only at this private seam so regressions
// can prove that malformed or lossy output never reaches filesystem staging.
fn save_checked_profile_with_source(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
source: impl FnOnce(&Project) -> Vec<u8>,
) -> Result<Vec<u8>, FileError> {
validate_project(p)?;
let mut bytes = if path
let is_source = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
crate::language::format(p).into_bytes()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
let mut bytes = if is_source {
source(p)
} else {
serde_json::to_vec_pretty(p)?
json_codec::encode(p, profile, depth_128)?
};
if !bytes.ends_with(b"\n") {
bytes.push(b'\n');
}
if is_source {
// Check exactly the final bytes, including their newline, through the
// public reader before creating a temp file or replacing a destination.
let decoded = decode_with_report_limited(
&bytes,
crate::language::ParseLimits {
max_source_bytes: profile.bytes,
..crate::language::ParseLimits::UNLIMITED
},
)?;
if decoded.project != *p {
return Err(FileError::Invalid(
"Generated source does not reproduce the complete requested project.".into(),
));
}
if decoded.migration.is_some() {
return Err(FileError::Invalid(
"Generated source unexpectedly requires migration.".into(),
));
}
}
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
@@ -895,8 +1007,8 @@ pub fn save_checked(
temp.write_all(&bytes)?;
temp.as_file().sync_all()?;
if let Some(expected) = expected {
match std::fs::read(path) {
Ok(current) if current == expected => {}
match json_codec::matches_file(path, expected) {
Ok(true) => {}
Ok(_) => return Err(FileError::Conflict),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
Err(e) => return Err(FileError::Io(e)),
@@ -0,0 +1,427 @@
//! Private persistence JSON boundary. Scan original bytes before Serde allocates;
//! the scanner is a resource meter, never a second accepting JSON grammar.
use super::FileError;
use crate::{
Project,
analysis::expression_serde::{self, Scope},
};
use serde::{
Deserialize,
de::{IgnoredAny, MapAccess, Visitor},
};
use std::{
fmt,
io::{self, Read, Write},
};
pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
const MAX_CONTAINERS: usize = 512;
// This switch stays false until Windows/Linux debug/release codec + ordinary
// lifecycle gates pass. Tests exercise the candidate through the same private
// entry point with depth_128=true. It is not a user-controlled parser option.
pub(super) const DEPTH_128_VALIDATED: bool = false;
/// Allocation cardinalities, independent of graph admission and numeric work.
/// The default deliberately admits the existing CLI's 100000 graph objects,
/// 250000 records and 500000 ordered references within its 64 MiB envelope.
/// Strings have no new 1 MiB restriction: any span fitting that envelope fits.
/// These are explicit slot/text bounds, not an assertion of process RSS.
#[derive(Clone, Copy, Debug)]
pub(super) struct Profile {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub container_entries: usize,
pub string_bytes: usize,
pub total_string_bytes: usize,
pub expression_nodes: usize,
}
impl Profile {
pub const DOCUMENT: Self = Self {
bytes: MAX_DOCUMENT_BYTES,
tokens: 16 * 1024 * 1024,
entries: 8 * 1024 * 1024,
container_entries: 500_000,
string_bytes: MAX_DOCUMENT_BYTES,
total_string_bytes: MAX_DOCUMENT_BYTES,
expression_nodes: expression_serde::MAX_JSON_EXPRESSION_NODES,
};
pub fn with_byte_limit(maximum: usize) -> Self {
Self {
bytes: maximum.min(MAX_DOCUMENT_BYTES),
..Self::DOCUMENT
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Usage {
pub tokens: usize,
pub entries: usize,
pub string_bytes: usize,
pub deepest_container: usize,
}
#[derive(Clone, Copy)]
struct Container {
delimiter: u8,
entries: usize,
first: bool,
}
const EMPTY: Container = Container {
delimiter: 0,
entries: 0,
first: true,
};
fn limit(message: &str) -> FileError {
FileError::Limit(message.into())
}
fn charge(used: &mut usize, amount: usize, maximum: usize, message: &str) -> Result<(), FileError> {
if amount > maximum.saturating_sub(*used) {
return Err(limit(message));
}
*used += amount;
Ok(())
}
pub(super) fn preflight(bytes: &[u8], profile: Profile) -> Result<Usage, FileError> {
if bytes.len() > profile.bytes {
return Err(limit("JSON input byte limit exceeded."));
}
// Fixed storage: no Vec/Value/String and no recursive calls during preflight.
let mut containers = [EMPTY; MAX_CONTAINERS];
let mut depth = 0usize;
let mut usage = Usage::default();
let mut cursor = 0usize;
while cursor < bytes.len() {
let byte = bytes[cursor];
if byte.is_ascii_whitespace() {
cursor += 1;
continue;
}
charge(
&mut usage.tokens,
1,
profile.tokens,
"JSON token limit exceeded.",
)?;
if !matches!(byte, b',' | b':' | b'}' | b']') && depth > 0 {
let frame = &mut containers[depth - 1];
if frame.first {
charge(
&mut frame.entries,
1,
profile.container_entries,
"JSON container entry limit exceeded.",
)?;
charge(
&mut usage.entries,
1,
profile.entries,
"JSON aggregate entry limit exceeded.",
)?;
frame.first = false;
}
}
match byte {
b'{' | b'[' => {
if depth == MAX_CONTAINERS {
return Err(limit("JSON container depth exceeds 512."));
}
containers[depth] = Container {
delimiter: byte,
..EMPTY
};
depth += 1;
usage.deepest_container = usage.deepest_container.max(depth);
cursor += 1;
}
b'}' | b']' => {
if depth == 0
|| !matches!(
(containers[depth - 1].delimiter, byte),
(b'{', b'}') | (b'[', b']')
)
{
return Err(FileError::Invalid(
"Mismatched JSON container delimiter.".into(),
));
}
depth -= 1;
cursor += 1;
}
b',' => {
if depth > 0 {
containers[depth - 1].first = true;
}
cursor += 1;
}
b':' => cursor += 1,
b'"' => {
cursor += 1;
let start = cursor;
let mut closed = false;
while cursor < bytes.len() {
match bytes[cursor] {
b'"' => {
closed = true;
break;
}
b'\\' => {
// Skip exactly one escaped byte, including an escaped
// backslash. Serde validates actual escapes/UTF-8.
cursor += 1;
if cursor < bytes.len() {
cursor += 1;
}
}
_ => cursor += 1,
}
}
let span = cursor - start;
if span > profile.string_bytes {
return Err(limit("JSON string byte limit exceeded."));
}
charge(
&mut usage.string_bytes,
span,
profile.total_string_bytes,
"JSON aggregate string byte limit exceeded.",
)?;
if !closed {
return Err(FileError::Invalid("Unterminated JSON string.".into()));
}
cursor += 1;
}
_ => {
// Count a scalar token without interpreting its value. Invalid
// tokens and invalid whitespace must still fail the Serde pass.
cursor += 1;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& !matches!(
bytes[cursor],
b'{' | b'[' | b'}' | b']' | b',' | b':' | b'"'
)
{
cursor += 1;
}
}
}
}
if depth != 0 {
return Err(FileError::Invalid("Unclosed JSON container.".into()));
}
Ok(usage)
}
struct Version(Option<u64>);
impl<'de> Deserialize<'de> for Version {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Header;
impl<'de> Visitor<'de> for Header {
type Value = Version;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a project header")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Version, A::Error> {
#[derive(Deserialize)]
#[serde(field_identifier)]
enum Key {
#[serde(rename = "schema_version")]
Schema,
#[serde(other)]
Other,
}
let mut version = None;
while let Some(key) = access.next_key::<Key>()? {
match key {
Key::Schema => {
if version.is_some() {
return Err(serde::de::Error::duplicate_field("schema_version"));
}
version = Some(access.next_value()?);
}
Key::Other => {
access.next_value::<IgnoredAny>()?;
}
}
}
Ok(Version(version))
}
}
deserializer.deserialize_map(Header)
}
}
/// IgnoredAny in serde_json walks arbitrary ignored containers iteratively.
/// This probe does not materialize ignored subtrees. Serde's scratch buffer for
/// container markers/escaped keys is bounded by the preceding raw scan. It
/// consumes the entire original stream and rejects duplicate/escaped versions.
pub(super) fn version(bytes: &[u8]) -> Result<Option<u64>, FileError> {
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
let version = Version::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(version.0)
})
}
pub(super) fn document_stack<R>(operation: impl FnOnce() -> R) -> R {
// Non-Expr Project and historical schemas are finitely nested by their
// concrete Rust types. Reserve for that outer generated Serde frame chain;
// only recursive Expr edges use the smaller repeated boundary guard.
stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, operation)
}
pub(super) fn current(
bytes: &[u8],
profile: Profile,
depth_128: bool,
) -> Result<Project, FileError> {
preflight(bytes, profile)?;
let _scope = Scope::decoding(profile.expression_nodes);
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
if depth_128 {
deserializer.disable_recursion_limit();
}
let project = Project::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(project)
})
}
pub(super) fn legacy<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, FileError> {
// Never disable the legacy recursion guard, including its temporary Value.
document_stack(|| Ok(serde_json::from_slice(bytes)?))
}
pub(super) fn discard_value(value: serde_json::Value) {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Array(values) => pending.extend(values),
serde_json::Value::Object(values) => pending.extend(values.into_values()),
_ => {}
}
}
}
fn reserve_bounded(bytes: &mut Vec<u8>, additional: usize, maximum: usize) -> io::Result<()> {
let required = bytes.len() + additional;
if required > bytes.capacity() {
let next = bytes
.capacity()
.max(4096)
.saturating_mul(2)
.max(required)
.min(maximum);
bytes
.try_reserve_exact(next - bytes.len())
.map_err(io::Error::other)?;
}
Ok(())
}
struct CappedWriter {
bytes: Vec<u8>,
maximum: usize,
exceeded: bool,
}
impl CappedWriter {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
exceeded: false,
}
}
}
impl Write for CappedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() > self.maximum.saturating_sub(self.bytes.len()) {
self.exceeded = true;
return Err(io::Error::other("JSON output byte limit exceeded."));
}
// Check before reserve/extend; failed output never creates a giant Vec.
reserve_bounded(&mut self.bytes, bytes.len(), self.maximum)?;
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// Compact output avoids indentation multiplying the cost of legal depth128.
/// The returned bytes have already passed the identical reader resource profile.
pub(super) fn encode(
project: &Project,
profile: Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
let _scope = Scope::encoding(profile.expression_nodes);
let mut writer = CappedWriter::new(profile.bytes);
let result = document_stack(|| serde_json::to_writer(&mut writer, project));
if writer.exceeded {
return Err(limit("JSON output byte limit exceeded."));
}
result?;
writer.write_all(b"\n").map_err(|error| {
if writer.exceeded {
limit("JSON output byte limit exceeded.")
} else {
FileError::Io(error)
}
})?;
// Establish save/reopen symmetry before filesystem mutation, including the
// conservative production guard until the deeper profile is validated.
let reopened = current(&writer.bytes, profile, depth_128)?;
if reopened != *project {
return Err(FileError::Invalid(
"JSON serialization did not preserve the project value.".into(),
));
}
Ok(writer.bytes)
}
pub(super) fn read_bounded(path: &std::path::Path, maximum: usize) -> Result<Vec<u8>, FileError> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() > maximum as u64 {
return Err(limit("Input file byte limit exceeded."));
}
let mut bytes = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(bytes);
}
if count > maximum.saturating_sub(bytes.len()) {
return Err(limit("Input file byte limit exceeded."));
}
reserve_bounded(&mut bytes, count, maximum)?;
bytes.extend_from_slice(&chunk[..count]);
}
}
pub(super) fn matches_file(path: &std::path::Path, expected: &[u8]) -> io::Result<bool> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() != expected.len() as u64 {
return Ok(false);
}
let mut compared = 0usize;
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(compared == expected.len());
}
if expected.get(compared..compared + count) != Some(&chunk[..count]) {
return Ok(false);
}
compared += count;
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,612 @@
use super::*;
use crate::{
Element, ElementBody, Id, OaBody,
analysis::{
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, Expr, Parameter,
ParameterId, ScalarType, UnaryOp,
},
language::ParseLimits,
persistence,
};
use std::{
fmt::Write as _,
process::{Command, Stdio},
time::{Duration, Instant},
};
const IDENTITY: &str = "00000000-0000-0000-0000-000000000001";
const PARAMETER: &str = "00000000-0000-0000-0000-000000000002";
const MAIN: &str = "00000000-0000-0000-0000-000000000003";
fn id(value: &str) -> Id {
Id::parse_str(value).unwrap()
}
fn boolean_type() -> AnalysisType {
AnalysisType::Scalar(ScalarType::Boolean)
}
fn definition(id: Id, alias: &str, parameters: Vec<Parameter>, body: Expr) -> Definition {
Definition {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body: DefinitionBody::Calculation(CalculationDefinition {
parameters,
result: boolean_type(),
body: Binding::Expression(body),
}),
}
}
fn project() -> Project {
let mut project = Project::empty();
project.id = Id::from_u128(1000);
project.definitions.insert(
id(IDENTITY),
definition(
id(IDENTITY),
"identity",
vec![Parameter {
id: ParameterId(id(PARAMETER)),
name: "value".into(),
value_type: boolean_type(),
}],
Expr::Parameter(ParameterId(id(PARAMETER))),
),
);
project.definitions.insert(
id(MAIN),
definition(id(MAIN), "main", vec![], Expr::Boolean(true)),
);
project
}
fn wire(expression: &str) -> Vec<u8> {
let shallow = serde_json::to_string(&project()).unwrap();
assert_eq!(shallow.matches(r#"{"Boolean":true}"#).count(), 1);
shallow
.replace(r#"{"Boolean":true}"#, expression)
.into_bytes()
}
fn chain(depth: usize, call: bool) -> String {
assert!(depth > 0);
let prefix = if call {
format!(r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":"#)
} else {
r#"{"Unary":{"op":"Not","operand":"#.into()
};
let suffix = if call { "}}}" } else { "}}" };
format!(
"{}{}{}",
prefix.repeat(depth - 1),
r#"{"Boolean":true}"#,
suffix.repeat(depth - 1)
)
}
fn candidate(bytes: &[u8], profile: Profile) -> Result<Project, FileError> {
persistence::decode_with_report_profile(bytes, ParseLimits::UNLIMITED, profile, true)
.map(|decoded| decoded.project)
}
#[test]
fn resource_preflight_has_exact_lowerable_boundaries() {
let bytes = br#"{"key":["a\\\"[{}]",0,true],"other":"\u0061"}"#;
let usage = preflight(bytes, Profile::DOCUMENT).unwrap();
let exact = Profile {
bytes: bytes.len(),
tokens: usage.tokens,
entries: usage.entries,
container_entries: 3,
string_bytes: 9,
total_string_bytes: usage.string_bytes,
..Profile::DOCUMENT
};
preflight(bytes, exact).unwrap();
for lower in [
Profile {
bytes: exact.bytes - 1,
..exact
},
Profile {
tokens: exact.tokens - 1,
..exact
},
Profile {
entries: exact.entries - 1,
..exact
},
Profile {
container_entries: 2,
..exact
},
Profile {
total_string_bytes: exact.total_string_bytes - 1,
..exact
},
Profile {
string_bytes: exact.string_bytes - 1,
..exact
},
] {
assert!(matches!(preflight(bytes, lower), Err(FileError::Limit(_))));
}
let deepest = format!("{}0{}", "[".repeat(512), "]".repeat(512));
assert_eq!(
preflight(deepest.as_bytes(), Profile::DOCUMENT)
.unwrap()
.deepest_container,
512
);
assert!(matches!(
preflight(format!("[{deepest}]").as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
// A huge ignored scalar array exhausts preflight before a header is decoded.
assert!(matches!(
preflight(
br#"{"schema_version":8,"junk":[0,0,0,0]}"#,
Profile {
container_entries: 3,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
}
#[test]
fn scanner_is_not_a_parallel_json_grammar() {
for bytes in [
br#"{"schema_version":8}{}"#.as_slice(),
br#"{"schema_version":8,}"#,
br#"{"schema_version":8 "junk":0}"#,
br#"{"schema_version":8,"schema_version":8}"#,
br#"{"schema_version":8,"\u0073chema_version":8}"#,
br#"{"schema_version":8,"junk":"\q"}"#,
br#"{"schema_version":8,"junk":"unfinished}"#,
br#"{"schema_version":8,"junk":[}"#,
] {
assert!(candidate(bytes, Profile::DOCUMENT).is_err(), "{bytes:?}");
}
let mut invalid_utf8 = wire(r#"{"Boolean":true}"#);
let offset = invalid_utf8
.windows(7)
.position(|window| window == b"mission")
.unwrap();
invalid_utf8[offset] = 0xff;
assert!(candidate(&invalid_utf8, Profile::DOCUMENT).is_err());
let escaped = String::from_utf8(wire(r#"{"Boolean":true}"#))
.unwrap()
.replace(r#""schema_version""#, r#""\u0073chema_version""#);
assert_eq!(
candidate(escaped.as_bytes(), Profile::DOCUMENT).unwrap(),
project()
);
}
#[test]
fn remote_serde_keeps_each_existing_expression_variant() {
let n = r#"{"Number":{"numerator":"1","denominator":"1"}}"#;
let b = r#"{"Boolean":true}"#;
let expressions = [
b.to_string(),
n.to_string(),
format!(
r#"{{"Quantity":{{"magnitude":{{"numerator":"1","denominator":"1"}},"unit":{{"Local":"{IDENTITY}"}}}}}}"#
),
format!(r#"{{"Property":"{IDENTITY}"}}"#),
format!(r#"{{"Parameter":"{PARAMETER}"}}"#),
format!(r#"{{"List":[{n},{n}]}}"#),
format!(r#"{{"Unary":{{"op":"Not","operand":{b}}}}}"#),
format!(r#"{{"Binary":{{"op":"Add","left":{n},"right":{n}}}}}"#),
format!(r#"{{"If":{{"condition":{b},"when_true":{n},"when_false":{n}}}}}"#),
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{b}}}}}}}"#
),
format!(r#"{{"Sum":{{"values":{{"List":[{n}]}},"initial":{n}}}}}"#),
format!(r#"{{"Power":{{"base":{n},"exponent":2}}}}"#),
format!(r#"{{"AsKind":{{"kind":{{"Local":"{IDENTITY}"}},"value":{n}}}}}"#),
];
for original in expressions {
let value: Expr = serde_json::from_str(&original).unwrap();
let copy = value.clone();
assert!(value == copy);
assert_eq!(serde_json::to_string(&value).unwrap(), original);
assert_eq!(format!("{value:?}"), format!("{copy:?}"));
}
let list = Expr::List(vec![Expr::Boolean(true), Expr::Boolean(false)]);
assert_eq!(format!("{list:?}"), "List([Boolean(true), Boolean(false)])");
assert!(list != Expr::List(vec![Expr::Boolean(false), Expr::Boolean(true)]));
}
#[test]
fn json_profile_does_not_contract_declared_cli_collection_counts() {
// Exercise original-byte typed construction at the existing graph ceiling.
// This gate checks codec/count compatibility, not 100000-object UI speed.
let mut model = Project::empty();
model.id = Id::from_u128(1_000_000);
for value in 1..=100_000 {
let id = Id::from_u128(value);
model.elements.insert(
id,
Element {
id,
alias: format!("activity_{value}"),
name: String::new(),
description: String::new(),
parent: None,
body: ElementBody::Oa(OaBody::Activity),
},
);
}
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let decoded = current(&bytes, Profile::DOCUMENT, true).unwrap();
assert_eq!(decoded.elements.len(), 100_000);
let cli = ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
};
cli.check_model(&decoded).unwrap();
let references = format!(
"[{}\"{IDENTITY}\"]",
format!("\"{IDENTITY}\",").repeat(499_999)
);
preflight(references.as_bytes(), Profile::DOCUMENT).unwrap();
assert!(matches!(
preflight(
format!("[\"{IDENTITY}\",{}]", &references[1..references.len() - 1]).as_bytes(),
Profile::DOCUMENT
),
Err(FileError::Limit(_))
));
// Metadata over the initially proposed 1 MiB limit remains admissible.
let mut text = Project::empty();
text.description = "x".repeat(1024 * 1024 + 1);
let bytes = encode(&text, Profile::DOCUMENT, true).unwrap();
assert_eq!(current(&bytes, Profile::DOCUMENT, true).unwrap(), text);
eprintln!(
"JSON profile: graph bytes={}, ordered-reference bytes={}, max container entries={}",
serde_json::to_string(&model).unwrap().len(),
references.len(),
Profile::DOCUMENT.container_entries
);
}
#[test]
fn production_save_always_reopens_with_its_reader_profile() {
let deep = candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.json");
std::fs::write(&path, b"previous").unwrap();
let saved = persistence::save_checked(&path, &deep, Some(b"previous"));
if DEPTH_128_VALIDATED {
let bytes = saved.unwrap();
assert_eq!(persistence::decode(&bytes).unwrap(), deep);
} else {
assert!(saved.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"previous");
}
}
fn accepted_phase() {
for call in [false, true] {
let bytes = wire(&chain(128, call));
let usage = preflight(&bytes, Profile::DOCUMENT).unwrap();
let model = candidate(&bytes, Profile::DOCUMENT).unwrap();
let copy = model.clone();
assert!(model == copy);
let debug = format!("{model:?}");
assert!(!debug.is_empty());
let output = encode(&model, Profile::DOCUMENT, true).unwrap();
assert!(candidate(&output, Profile::DOCUMENT).unwrap() == model);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("deep.json");
let saved = persistence::save_checked_profile(&path, &model, None, Profile::DOCUMENT, true)
.unwrap();
assert!(
candidate(
&read_bounded(&path, MAX_DOCUMENT_BYTES).unwrap(),
Profile::DOCUMENT
)
.unwrap()
== model
);
assert_eq!(saved, output);
eprintln!(
"accepted call={call} depth128 input={} output={} tokens={} entries={} raw strings={} containers={} debug={}",
bytes.len(),
output.len(),
usage.tokens,
usage.entries,
usage.string_bytes,
usage.deepest_container,
debug.len()
);
}
}
fn refused_phase() {
for call in [false, true] {
let error = candidate(&wire(&chain(129, call)), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("logical depth"), "{error}");
}
let wide = format!(
r#"{{"List":[{}null]}}"#,
r#"{"Boolean":true},"#.repeat(1024)
);
let error = candidate(&wire(&wide), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("1024"), "{error}");
let bytes = wire(&chain(128, true));
let exact_model = candidate(
&bytes,
Profile {
expression_nodes: 129,
..Profile::DOCUMENT
},
)
.unwrap();
let error = candidate(
&bytes,
Profile {
expression_nodes: 128,
..Profile::DOCUMENT
},
)
.unwrap_err();
// Identity's Parameter plus main's 128-node expression exceed this total.
assert!(error.to_string().contains("allocation count"), "{error}");
let scan = preflight(&bytes, Profile::DOCUMENT).unwrap();
assert!(matches!(
candidate(
&bytes,
Profile {
tokens: scan.tokens - 1,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
document_stack(|| {
use crate::analysis::{AdmissionLimits, admit};
let events = admit(&exact_model, AdmissionLimits::REFERENCE)
.unwrap()
.admission_usage()
.static_events;
admit(
&exact_model,
AdmissionLimits {
max_static_events: events,
},
)
.unwrap();
let error = match admit(
&exact_model,
AdmissionLimits {
max_static_events: events - 1,
},
) {
Ok(_) => panic!("Static work below the measured exact count must refuse."),
Err(error) => error,
};
assert_eq!(error.code, "analysis_limit");
});
let mut tree = r#"{"Boolean":true}"#.to_owned();
for _ in 0..15 {
tree = format!(r#"{{"Binary":{{"op":"And","left":{tree},"right":{tree}}}}}"#);
}
// 65535 balanced nodes plus one unary node is the stored-tree boundary.
tree = format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#);
let at_tree_limit = wire(&tree);
current(&at_tree_limit, Profile::DOCUMENT, true).unwrap();
let beyond = wire(&format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#));
assert!(
current(&beyond, Profile::DOCUMENT, true)
.unwrap_err()
.to_string()
.contains("65536")
);
// No downstream work after refusal poisons the next ordinary operation.
candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
}
fn malformed_phase() {
let deep = chain(127, false);
for (label, expression) in [
(
"duplicate operand",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"operand":null}}}}"#),
),
(
"escaped duplicate",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"\u006fperand":null}}}}"#),
),
(
"unknown field",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"surprise":null}}}}"#),
),
(
"duplicate enum",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep}}},"Boolean":true}}"#),
),
(
"duplicate parameter",
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{deep},"\u0030{}":null}}}}}}"#,
&PARAMETER[1..]
),
),
] {
let error = candidate(&wire(&expression), Profile::DOCUMENT).unwrap_err();
assert!(
!error.to_string().contains("logical depth"),
"{label}: {error}"
);
eprintln!("{label}: {error}");
}
let complete = String::from_utf8(wire(&chain(128, true))).unwrap();
let duplicate_late = format!(
r#"{},"name":"late duplicate"}}"#,
&complete[..complete.len() - 1]
);
assert!(candidate(duplicate_late.as_bytes(), Profile::DOCUMENT).is_err());
assert!(candidate(format!("{complete} null").as_bytes(), Profile::DOCUMENT).is_err());
let invalid_surrogate = complete.replace(r#""description":"""#, r#""description":"\uD800""#);
assert!(candidate(invalid_surrogate.as_bytes(), Profile::DOCUMENT).is_err());
// Fully rejected raw depth also tears down without a recursive JSON Value.
let excessive = format!("{}0{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(matches!(
preflight(excessive.as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
}
fn ownership_phase() {
let mut value = Expr::Boolean(true);
for _ in 1..100_000 {
value = Expr::Unary {
op: UnaryOp::Not,
operand: Box::new(value),
};
}
let mut copy = value.clone();
assert!(value == copy);
let mut cursor = &mut copy;
loop {
match cursor {
Expr::Unary { operand, .. } => cursor = operand,
Expr::Boolean(leaf) => {
*leaf = false;
break;
}
_ => unreachable!(),
}
}
assert!(value != copy);
// A streaming formatter avoids retaining a 100000-node Debug string.
struct Counter(usize);
impl fmt::Write for Counter {
fn write_str(&mut self, value: &str) -> fmt::Result {
self.0 += value.len();
Ok(())
}
}
let mut debug = Counter(0);
write!(&mut debug, "{value:?}").unwrap();
assert!(debug.0 > 100_000);
assert!(serde_json::to_writer(io::sink(), &value).is_err());
drop(value);
drop(copy);
eprintln!(
"iterative manual100000 ownership/debug/drop completed; debug bytes={}",
debug.0
);
}
fn atomic_phase() {
let model = project();
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let exact = Profile {
bytes: bytes.len(),
..Profile::DOCUMENT
};
assert_eq!(encode(&model, exact, true).unwrap(), bytes);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("preserved.json");
std::fs::write(&path, b"original").unwrap();
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"original"),
Profile {
bytes: bytes.len() - 1,
..Profile::DOCUMENT
},
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Limit(_)));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"different"),
Profile::DOCUMENT,
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Conflict));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
assert!(read_bounded(&path, 7).is_err());
assert_eq!(read_bounded(&path, 8).unwrap(), b"original");
let mut writer = CappedWriter::new(3);
writer.write_all(b"abc").unwrap();
let capacity = writer.bytes.capacity();
assert!(writer.write_all(b"d").is_err());
assert_eq!(writer.bytes, b"abc");
assert_eq!(writer.bytes.capacity(), capacity);
}
/// Root runs this exact test in each approved target/profile. Each phase runs in
/// another process so a stack overflow is an explicit failed phase, and inside
/// a 128 KiB thread (smaller than the ordinary test stack). No RUST_MIN_STACK
/// override, ignored test, release-only path or increased test stack.
#[test]
fn codec_lifecycle_subprocess() {
const PHASE: &str = "ARCADIA_CODEC_LIFECYCLE_PHASE";
if let Ok(phase) = std::env::var(PHASE) {
eprintln!("codec child phase={phase}, configured thread stack=131072");
std::thread::Builder::new()
.name(format!("codec-{phase}"))
.stack_size(128 * 1024)
.spawn(move || match phase.as_str() {
"accepted" => accepted_phase(),
"refused" => refused_phase(),
"malformed" => malformed_phase(),
"ownership" => ownership_phase(),
"atomic" => atomic_phase(),
_ => panic!("Unknown codec phase"),
})
.unwrap()
.join()
.unwrap();
return;
}
for phase in ["accepted", "refused", "malformed", "ownership", "atomic"] {
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"persistence::json_codec::tests::codec_lifecycle_subprocess",
"--test-threads=1",
"--nocapture",
])
.env(PHASE, phase)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
break;
}
if start.elapsed() > Duration::from_secs(120) {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!(
"codec {phase} timed out: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(20));
}
let output = child.wait_with_output().unwrap();
eprintln!(
"codec {phase}: status={} elapsed={:?}\n{}{}",
output.status,
start.elapsed(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.status.success(),
"codec phase {phase} aborted or failed"
);
}
}
@@ -0,0 +1,145 @@
//! Containment while the complete source8 adapter remains unavailable.
use super::*;
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
use crate::analysis_numeric::Dimension;
use std::fs;
const ORDINARY_SOURCE7: &str = concat!(
"arcadia 7;\n",
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
" description \"Retain café and exact identity\";\n",
"}",
);
fn ordinary() -> Project {
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert!(decoded.migration.is_some());
decoded.project
}
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
let mut names = fs::read_dir(path)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
names.sort();
names
}
// A missing parent makes a mistakenly early NamedTempFile creation observable
// as Io, even when the temp would otherwise be removed again on error.
fn rejected_output(
project: &Project,
output: &[u8],
profile: json_codec::Profile,
check: impl Fn(&FileError),
) {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("existing.arcadia");
let sentinel = b"external bytes must survive\r\n";
fs::write(&existing, sentinel).unwrap();
for path in [
existing.clone(),
root.path().join("new.arcadia"),
root.path().join("missing-parent/new.arcadia"),
] {
let before = directory_entries(root.path());
let expected = (path == existing).then_some(sentinel.as_slice());
let error =
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
output.to_vec()
})
.unwrap_err();
check(&error);
assert_eq!(directory_entries(root.path()), before);
assert_eq!(fs::read(&existing).unwrap(), sentinel);
if path != existing {
assert!(!path.exists());
}
}
}
#[test]
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
rejected_output(
&ordinary(),
b"arcadia",
json_codec::Profile::DOCUMENT,
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
);
}
#[test]
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
let mut project = ordinary();
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
project.definitions.insert(
id,
Definition {
id,
alias: "duration".into(),
name: "Duration".into(),
description: "Unused definitions are still authored model data.".into(),
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
},
);
validate_project(&project).unwrap();
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert_eq!(decoded.id, project.id);
assert_eq!(decoded.name, project.name);
assert_eq!(decoded.elements, project.elements);
assert!(decoded.definitions.is_empty());
assert_ne!(decoded, project);
rejected_output(
&project,
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source does not reproduce the complete requested project."),
"{error}"
);
},
);
}
#[test]
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
rejected_output(
&ordinary(),
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
},
);
}
#[test]
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
let project = ordinary();
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
assert!(matches!(error, FileError::Limit(_)), "{error}");
});
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
terminated.push(b'\n');
let profile = json_codec::Profile::with_byte_limit(terminated.len());
// Existing final newline is not added twice: decoding now reaches the
// independent migration refusal, rather than exceeding this exact limit.
rejected_output(&project, &terminated, profile, |error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
});
}
+17 -2
View File
@@ -1,8 +1,14 @@
use crate::*;
use std::collections::BTreeSet;
/// Every live reference in a typed element, including structural ownership.
/// Architectural/project references, including analysis subjects/properties.
/// Layer-neutral definition references and scoped parameters have separate domains.
pub fn element_references(e: &Element) -> Vec<Id> {
if e.kind().is_analysis() {
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
references.extend(e.parent);
return references;
}
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
@@ -14,6 +20,8 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
/// Architectural mutable slots only. Analysis map keys require the fallible
/// whole-project remap; use analysis::references for its typed domains.
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
@@ -203,7 +211,10 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
crate::analysis::references::remap_project(p, old, new);
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -265,6 +276,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -405,6 +417,9 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
// Analysis dependencies remain live: deleting their target rejects the
// transaction unless that transaction also explicitly repairs/removes them.
p.definitions.retain(|id, _| !removed.contains(id));
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
+3
View File
@@ -604,6 +604,9 @@ fn catalog_facets(
include_linked: bool,
) -> Result<Vec<FacetDrift>, String> {
let (oa, sa) = (trace.oa, trace.sa);
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
}
let mut selectors: BTreeSet<_> = scalar_selectors()
.into_iter()
.filter(|s| {
+66 -1
View File
@@ -19,6 +19,21 @@ pub enum Command {
},
CreateElement(Element),
EditElement(Id, Element),
CreateDefinition(crate::analysis::Definition),
EditDefinition(Id, crate::analysis::Definition),
SetAnalysisBase {
property: Id,
binding: crate::analysis::Binding,
},
SetAnalysisOverride {
case: Id,
property: Id,
binding: crate::analysis::Binding,
},
ClearAnalysisOverride {
case: Id,
property: Id,
},
CreateRelation(Relation),
EditRelation(Id, Relation),
CreateDiagram(Diagram),
@@ -453,6 +468,47 @@ fn apply_command(
}
Some(value.id)
}
Command::CreateDefinition(value) => {
vacant(p, value.id)?;
p.definitions.insert(value.id, value.clone());
Some(value.id)
}
Command::EditDefinition(old, value) => {
if !p.definitions.contains_key(old) { return Err(missing()); }
let mut definition = value.clone();
definition.id = *old;
p.definitions.insert(*old, definition);
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetAnalysisBase { property, binding } => {
let element = p.elements.get_mut(property).ok_or_else(missing)?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
};
*base = binding.clone();
Some(*property)
}
Command::SetAnalysisOverride { case, property, binding } => {
let element = p.elements.get_mut(case).ok_or_else(missing)?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
};
overrides.insert(*property, binding.clone());
Some(*case)
}
Command::ClearAnalysisOverride { case, property } => {
let target = p.elements.get(property).ok_or_else(missing)?;
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
let layer = target.layer();
let element = p.elements.get_mut(case).ok_or_else(missing)?;
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
};
overrides.remove(property);
Some(*case)
}
Command::CreateRelation(value) => {
vacant(p, value.id)?;
p.relations.insert(value.id, value.clone());
@@ -835,6 +891,10 @@ fn edit_header(
assign(&mut p.name, &mut p.description, &mut p.alias);
return Ok(());
}
if let Some(v) = p.definitions.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.elements.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
@@ -878,8 +938,13 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.definitions.remove(&old) {
v.id = new;
p.definitions.insert(new, v);
} else if let Some(mut v) = p.elements.remove(&old) {
v.id = new;
p.elements.insert(new, v);
@@ -904,7 +969,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new);
crate::references::remap_references(p, old, new)?;
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
+7 -147
View File
@@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
mod source_migration;
#[path = "source_project/source_structure.rs"]
mod source_structure;
pub(crate) mod traversal;
/// A portable slash-separated path, checked at every bundle boundary.
pub type LogicalPath = String;
@@ -304,71 +305,7 @@ fn compile_once(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<(CompiledSourceProject, Edits), Vec<SourceDiagnostic>> {
let fail = |code, message| {
vec![problem(
bundle,
&bundle.entry,
Span::default(),
code,
message,
)]
};
if bundle.entry.contains('/') {
return Err(fail(
"invalid_path",
"The entry file must be at the source project root.".into(),
));
}
if bundle.files.len() > limits.max_files {
return Err(fail(
"project_limit",
format!("Source project exceeds {} files.", limits.max_files),
));
}
let bytes = bundle
.files
.values()
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
if bytes > limits.model.max_source_bytes {
return Err(fail(
"source_limit",
format!(
"Combined source exceeds {} bytes.",
limits.model.max_source_bytes
),
));
}
let mut folded = BTreeMap::new();
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
if resolve_include("", path).as_ref() != Ok(path) {
return Err(fail(
"invalid_path",
format!("Non-canonical source path '{path}'."),
));
}
if let Some(previous) = folded.insert(path.to_lowercase(), path)
&& previous != path
{
return Err(fail(
"duplicate_path",
format!("Case-colliding source paths '{previous}' and '{path}'."),
));
}
}
if !bundle.files.contains_key(&bundle.entry) {
return Err(fail(
"missing_include",
"The entry file is missing from the source bundle.".into(),
));
}
enum Visit {
Enter(LogicalPath, usize, Option<SourceLocation>),
Leave,
}
let mut stack = vec![Visit::Enter(bundle.entry.clone(), 0, None)];
let mut active: Vec<LogicalPath> = Vec::new();
let mut visited = BTreeSet::new();
let mut traversal = traversal::Traversal::new(bundle, limits)?;
let mut declarations = Vec::new();
let mut declaration_paths = Vec::new();
let mut aliases = BTreeMap::new();
@@ -381,51 +318,8 @@ fn compile_once(
let mut includes = Vec::new();
let mut insertions: Edits = BTreeMap::new();
let mut graph_count = 0usize;
while let Some(visit) = stack.pop() {
let Visit::Enter(path, depth, origin) = visit else {
active.pop();
continue;
};
let at = origin.unwrap_or(SourceLocation {
path: path.clone(),
span: Span::default(),
});
if let Some(start) = active.iter().position(|file| file == &path) {
let chain = active[start..]
.iter()
.chain(std::iter::once(&path))
.cloned()
.collect::<Vec<_>>()
.join(" -> ");
return Err(vec![problem(
bundle,
&at.path,
at.span,
"include_cycle",
format!("Include cycle: {chain}."),
)]);
}
if depth > limits.max_include_depth {
return Err(vec![problem(
bundle,
&at.path,
at.span,
"project_limit",
format!("Include depth exceeds {}.", limits.max_include_depth),
)]);
}
if !visited.insert(path.clone()) {
continue;
}
let Some(source) = bundle.files.get(&path) else {
return Err(vec![problem(
bundle,
&at.path,
at.span,
"missing_include",
format!("Included file '{path}' is missing."),
)]);
};
while let Some(path) = traversal.next()? {
let source = &bundle.files[&path];
let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err(
|diagnostic| {
vec![SourceDiagnostic {
@@ -513,46 +407,12 @@ fn compile_once(
declarations.push(declaration);
declaration_paths.push(path.clone());
}
let mut children = Vec::new();
for (raw, span) in unit.includes {
let target = resolve_include(&path, &raw)
.map_err(|message| vec![problem(bundle, &path, span, "invalid_path", message)])?;
let location = SourceLocation {
path: path.clone(),
span,
};
includes.push(IncludeLocation {
location: location.clone(),
target: target.clone(),
});
if includes.len() > limits.max_include_edges {
return Err(vec![problem(
bundle,
&path,
span,
"project_limit",
format!("Include graph exceeds {} edges.", limits.max_include_edges),
)]);
}
children.push(Visit::Enter(
target,
depth.saturating_add(1),
Some(location),
));
includes.push(traversal.include(&path, &raw, span)?);
}
active.push(path);
stack.push(Visit::Leave);
stack.extend(children.into_iter().rev());
}
if let Some(path) = bundle.files.keys().find(|path| !visited.contains(*path)) {
return Err(vec![problem(
bundle,
path,
Span::default(),
"unreachable_file",
"Supplied source file is not reachable from the entry file.",
)]);
traversal.finish_file(path);
}
traversal.finish()?;
let header = project_header.expect("validated entry header");
let index = SourceIndex {
declarations: declaration_locations,
@@ -0,0 +1,258 @@
//! Shared include traversal policy for ordinary compilation and private syntax.
use super::*;
enum Visit {
Enter(LogicalPath, usize, Option<SourceLocation>),
Leave,
}
pub(crate) struct Traversal<'a> {
bundle: &'a SourceBundle,
limits: ProjectLimits,
stack: Vec<Visit>,
active: Vec<LogicalPath>,
visited: BTreeSet<LogicalPath>,
children: Vec<Visit>,
depth: usize,
edges: usize,
}
impl<'a> Traversal<'a> {
pub(crate) fn new(
bundle: &'a SourceBundle,
limits: ProjectLimits,
) -> Result<Self, Vec<SourceDiagnostic>> {
let fail = |code, message| {
vec![problem(
bundle,
&bundle.entry,
Span::default(),
code,
message,
)]
};
if bundle.entry.contains('/') {
return Err(fail(
"invalid_path",
"The entry file must be at the source project root.".into(),
));
}
if bundle.files.len() > limits.max_files {
return Err(fail(
"project_limit",
format!("Source project exceeds {} files.", limits.max_files),
));
}
let bytes = bundle
.files
.values()
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
if bytes > limits.model.max_source_bytes {
return Err(fail(
"source_limit",
format!(
"Combined source exceeds {} bytes.",
limits.model.max_source_bytes
),
));
}
let mut folded = BTreeMap::new();
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
if resolve_include("", path).as_ref() != Ok(path) {
return Err(fail(
"invalid_path",
format!("Non-canonical source path '{path}'."),
));
}
if let Some(previous) = folded.insert(path.to_lowercase(), path)
&& previous != path
{
return Err(fail(
"duplicate_path",
format!("Case-colliding source paths '{previous}' and '{path}'."),
));
}
}
if !bundle.files.contains_key(&bundle.entry) {
return Err(fail(
"missing_include",
"The entry file is missing from the source bundle.".into(),
));
}
Ok(Self {
bundle,
limits,
stack: vec![Visit::Enter(bundle.entry.clone(), 0, None)],
active: Vec::new(),
visited: BTreeSet::new(),
children: Vec::new(),
depth: 0,
edges: 0,
})
}
pub(crate) fn next(&mut self) -> Result<Option<LogicalPath>, Vec<SourceDiagnostic>> {
while let Some(visit) = self.stack.pop() {
let Visit::Enter(path, depth, origin) = visit else {
self.active.pop();
continue;
};
let at = origin.unwrap_or(SourceLocation {
path: path.clone(),
span: Span::default(),
});
if let Some(start) = self.active.iter().position(|file| file == &path) {
let chain = self.active[start..]
.iter()
.chain(std::iter::once(&path))
.cloned()
.collect::<Vec<_>>()
.join(" -> ");
return Err(vec![problem(
self.bundle,
&at.path,
at.span,
"include_cycle",
format!("Include cycle: {chain}."),
)]);
}
if depth > self.limits.max_include_depth {
return Err(vec![problem(
self.bundle,
&at.path,
at.span,
"project_limit",
format!("Include depth exceeds {}.", self.limits.max_include_depth),
)]);
}
if !self.visited.insert(path.clone()) {
continue;
}
if !self.bundle.files.contains_key(&path) {
return Err(vec![problem(
self.bundle,
&at.path,
at.span,
"missing_include",
format!("Included file '{path}' is missing."),
)]);
}
self.depth = depth;
return Ok(Some(path));
}
Ok(None)
}
pub(crate) fn include(
&mut self,
path: &str,
raw: &str,
span: Span,
) -> Result<IncludeLocation, Vec<SourceDiagnostic>> {
let target = resolve_include(path, raw)
.map_err(|message| vec![problem(self.bundle, path, span, "invalid_path", message)])?;
let location = SourceLocation {
path: path.into(),
span,
};
if self.edges >= self.limits.max_include_edges {
return Err(vec![problem(
self.bundle,
path,
span,
"project_limit",
format!(
"Include graph exceeds {} edges.",
self.limits.max_include_edges
),
)]);
}
self.edges += 1;
self.children.push(Visit::Enter(
target.clone(),
self.depth.saturating_add(1),
Some(location.clone()),
));
Ok(IncludeLocation { location, target })
}
pub(crate) fn finish_file(&mut self, path: LogicalPath) {
self.active.push(path);
self.stack.push(Visit::Leave);
self.stack.extend(self.children.drain(..).rev());
}
pub(crate) fn finish(self) -> Result<(), Vec<SourceDiagnostic>> {
if let Some(path) = self
.bundle
.files
.keys()
.find(|path| !self.visited.contains(*path))
{
return Err(vec![problem(
self.bundle,
path,
Span::default(),
"unreachable_file",
"Supplied source file is not reachable from the entry file.",
)]);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_compile_once_keeps_include_order_and_cycle_source_location() {
let mut bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), "arcadia 7; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" { include \"dir/left.arcadia\"; include \"dir/right.arcadia\"; }".into()),
("dir/left.arcadia".into(), "include \"../shared.arcadia\";".into()),
("dir/right.arcadia".into(), "include \"../shared.arcadia\";".into()),
("shared.arcadia".into(), "// one shared ordinary fragment\n".into()),
]),
};
let original = bundle.clone();
// This is the existing initial ordinary pass, before schema migration's
// separate output/reparse. It makes no public source8 acceptance claim.
let (compiled, insertions) =
super::super::compile_once(&bundle, ProjectLimits::default()).unwrap();
assert!(insertions.is_empty());
assert_eq!(compiled.bundle, original);
assert_eq!(compiled.index.declarations.len(), 1);
assert_eq!(
compiled
.index
.includes
.iter()
.map(|i| (i.location.path.as_str(), i.target.as_str()))
.collect::<Vec<_>>(),
[
("main.arcadia", "dir/left.arcadia"),
("main.arcadia", "dir/right.arcadia"),
("dir/left.arcadia", "shared.arcadia"),
("dir/right.arcadia", "shared.arcadia"),
]
);
let cycle = "// 雪\r\ninclude \"main.arcadia\";";
bundle.files.insert("shared.arcadia".into(), cycle.into());
let before = bundle.clone();
let errors = super::super::compile_once(&bundle, ProjectLimits::default()).unwrap_err();
assert_eq!(errors[0].diagnostic.code, "include_cycle");
assert_eq!(errors[0].path, "shared.arcadia");
assert_eq!(errors[0].diagnostic.line, 2);
assert_eq!(
&cycle[errors[0].diagnostic.span.start..errors[0].diagnostic.span.end],
"include \"main.arcadia\";"
);
assert_eq!(
errors[0].diagnostic.message,
"Include cycle: main.arcadia -> dir/left.arcadia -> shared.arcadia -> main.arcadia."
);
assert_eq!(bundle, before);
}
}
+10
View File
@@ -449,6 +449,13 @@ pub fn plan_transition(
request: &TransitionRequest,
) -> Result<TransitionPlan, ModelError> {
validate_candidate(project)?;
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
if request.sources.iter().any(is_analysis)
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|| mapping.target.as_ref().is_some_and(is_analysis)
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
}
let mut plan = TransitionPlan {
base_fingerprint: project_fingerprint(project),
request: request.clone(),
@@ -608,6 +615,9 @@ pub fn plan_transition(
}
}
}
if selected.iter().any(is_analysis) {
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
}
selected.retain(|id| {
if omitted.contains(id) {
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
+12
View File
@@ -180,6 +180,18 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
}
pub fn validate(p: &Project) -> Vec<Issue> {
// Bound analysis trees before architectural visitors can clone an element.
// Admission itself never enters this public validator recursively.
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
let mut out = Vec::new();
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
return out;
}
validate_structure(p)
}
/// Architecture/header validation without recursively re-entering analysis admission.
pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh);
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
-1
View File
@@ -18,7 +18,6 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
+64 -574
View File
@@ -2,15 +2,11 @@ pub(crate) mod architecture_authoring;
#[cfg(test)]
mod architecture_authoring_tests;
mod checkpoints;
mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
mod hosted;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
mod source_symbols;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -61,7 +57,7 @@ mod direct_scenario_tests {
}
}
#[test]
fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() {
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
@@ -77,26 +73,18 @@ mod direct_scenario_tests {
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)),
)));
assert_ne!(app.scenario_context(), Some(context));
let context = app.scenario_context().unwrap();
for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] {
for (area, offset) in [
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
] {
let before = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
offset,
context, area, offset,
)));
assert_ne!(app.scenario_context(), Some(before));
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
offset,
context, area, offset,
)));
assert_eq!(
app.scenario_context(),
@@ -125,7 +113,7 @@ mod direct_scenario_tests {
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
scenario::ScrollArea::Page,
scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(app.scenario_context(), Some(current));
@@ -330,7 +318,6 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
BehaviorControl(behavior::canvas_workspace::Intent),
Architecture(architecture_authoring::Event),
Home(crate::home::Event),
CheckpointTick,
@@ -384,8 +371,6 @@ pub enum Message {
FieldValuePath(String, crate::data_values::FieldPathKey),
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
FieldReferenceBrowse(String, String, usize),
FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open),
FieldReferenceEvent(crate::forms::reference_chooser::Event),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
@@ -430,18 +415,9 @@ pub enum Message {
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
crate::source_navigation::SymbolEvent,
),
SourceSymbolsReady(crate::source_navigation::Completion),
DiagramUsage(crate::diagram_usage::Event),
DiagramUsagesReady(crate::diagram_usage::Completion),
DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size),
SourceViewport,
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
SourceFindClose(crate::source_navigation::SymbolContext, Id),
Save(bool),
SavePath(Option<PathBuf>),
Saved(Result<Option<FileSnapshot>, SaveFailure>),
@@ -469,14 +445,10 @@ pub struct App {
pub hosted: crate::hosted::State,
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
reference_authoring: reference_authoring::State,
pub source_tree: crate::source_tree::State,
menu_sequence: u64,
pub canvas_document: Id,
pub canvas_epoch: u64,
/// Camera batches survive their own updates, never external presentation changes.
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub exact_strings: bool,
pub document: Document,
@@ -519,17 +491,6 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
source_select_line: bool,
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
u64,
)>,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_version: u64,
@@ -586,13 +547,10 @@ impl App {
checkpoint_close: None,
connection: None,
model_menu: None,
reference_authoring: Default::default(),
source_tree: Default::default(),
menu_sequence: 0,
canvas_document: Id::new_v4(),
canvas_epoch: 0,
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
exact_strings: false,
document,
@@ -638,13 +596,6 @@ impl App {
source_jump: None,
source_select_line: false,
source_navigation: crate::source_navigation::State::default(),
source_find_instance: Id::new_v4(),
source_symbols: crate::source_navigation::SymbolState::default(),
diagram_usages: None,
diagram_navigation_focus: None,
source_diagram_job: None,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
model_issues,
migration: None,
source_version: 0,
@@ -698,7 +649,6 @@ impl App {
self.lint_requested = None;
}
fn show_source_file(&mut self, restore_cursor: bool) {
self.source_symbols.reveal = None;
self.source_tree.reveal(&self.source_workspace.active);
self.source_select_line = false;
let source = self.source_workspace.text().to_owned();
@@ -713,9 +663,6 @@ impl App {
.map(|b| b.cursor)
})
.flatten();
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
}
fn selected_architecture_placement(&self) -> Option<Id> {
self.active_diagram
@@ -907,12 +854,8 @@ impl App {
let scenario_selection = self
.behavior
.scenario
.created_selection(self.project(), &command)
.or_else(|| {
self.behavior
.topology
.created_selection(self.project(), &command)
});
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(created) = created {
@@ -969,8 +912,6 @@ impl App {
let canvas_size = self.canvas_size;
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let navigation_in_flight = self.source_symbols.in_flight;
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
@@ -982,8 +923,6 @@ impl App {
self.canvas_size = canvas_size;
self.source_version = source_version;
self.lint_running = lint_running;
self.source_symbols.in_flight = navigation_in_flight;
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1084,72 +1023,16 @@ impl App {
crate::model_menu::popup(self),
)
.into();
crate::source_navigation::interaction::observe(
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(
self,
self.reference_overlay(crate::diagram_usage::view::overlay(self, base)),
),
behavior::canvas_workspace::Scope::input_owner(self),
),
self.scenario_focus_owner(),
),
self.source_symbols.raw_interaction.clone(),
)
crate::hosted::view::overlay(self, base)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
self.diagram_usages
.as_ref()
.map_or_else(Subscription::none, |panel| {
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
..
}) = event
else {
return None;
};
if !modifiers.is_empty() {
return None;
}
match key {
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8),
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8),
keyboard::Key::Named(keyboard::key::Named::Enter)
if status == iced::event::Status::Ignored =>
{
Some(0i8)
}
_ => None,
}
})
.with(panel.token)
.map(|(token, step)| {
Message::DiagramUsage(if step == 0 {
crate::diagram_usage::Event::Activate(token)
} else {
crate::diagram_usage::Event::Move(token, step)
})
})
}),
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.source_symbols.open || self.source_navigation.open)
&& self.architecture.is_none()),
)
.map(|()| Message::Escape),
behavior::creation::subscription(self.behavior.creation.has_pending())
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -1194,201 +1077,6 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let usage_viewport = self.diagram_reveal_viewport(&message);
let window_resized = matches!(&message, Message::WindowResized(_));
// Reject stale measurements before legacy viewport handlers can change
// dimensions or invalidate the exact pending destination epoch.
if self.pending_diagram_reveal.is_some()
&& Self::is_diagram_viewport(&message)
&& usage_viewport.is_none()
{
return Task::none();
}
self.reconcile_pending_diagram_reveal(&message);
if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent);
}
let camera = matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
);
let before = self.camera_boundary();
let task = self.update_inner(message);
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
*epoch = self.canvas_epoch;
}
if let Some(surface) = usage_viewport {
self.finish_diagram_reveal(surface);
}
if self
.diagram_navigation_focus
.as_ref()
.is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none())
{
self.diagram_navigation_focus = None;
}
if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
}
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
use behavior::canvas_workspace::{self, Event as Camera, Scope};
use behavior::scenario::CanvasEvent as Scenario;
use behavior::topology::CanvasEvent as Process;
if Scope::input_owner(self) != Some(intent.scope) {
return Task::none();
}
let mut message = *intent.message;
let scope = intent.scope;
let valid_scenario = |c: behavior::scenario::Context| {
c.document == scope.document
&& c.scenario == scope.owner
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
let valid_process = |c: behavior::topology::Context| {
c.document == scope.document
&& c.owner == scope.owner
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
// This whitelist contains only the production presentation controls.
// A stamped toolbar cannot smuggle an arbitrary authoring command.
match &mut message {
Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus))
if valid_scenario(*c) && *lifetime == scope.epoch =>
{
return canvas_workspace::focus_task(scope, Camera::Focus);
}
Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus))
if valid_process(*c) && *lifetime == scope.epoch =>
{
return canvas_workspace::focus_task(scope, Camera::Focus);
}
Message::ScenarioCanvas(
Scenario::Zoom(c, _)
| Scenario::Choose(c, _)
| Scenario::Focus(c)
| Scenario::Shelf(c, _),
) if valid_scenario(*c) => {
c.epoch = self.canvas_epoch;
}
Message::TopologyCanvas(
Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c),
) if valid_process(*c) => {
c.epoch = self.canvas_epoch;
}
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
if self.topology_context().is_some() => {}
_ => return Task::none(),
}
let task = self.update(message);
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
// after the action, for Iced's final-view ordering.
if self.camera_epoch == scope.epoch {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
}
let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| {
canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name))
});
Task::batch([task, focus])
}
fn camera_toolbar_epoch(&self, epoch: u64) -> bool {
epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch
}
fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_epoch,
self.canvas_document,
self.document.revision(),
crate::behavior::selected_owner(self),
self.page,
self.window_size,
crate::behavior::canvas_workspace::keyboard_enabled(self),
self.behavior.has_pending(),
self.draft_dirty(),
self.source_dirty(),
self.behavior.topology.camera_boundary(),
self.behavior.scenario.camera_boundary(),
)
}
fn update_inner(&mut self, message: Message) -> Task<Message> {
let message = match message {
Message::SourceFindClose(context, instance) => {
if context != self.source_symbol_context()
|| instance != self.source_find_instance
|| !self.source_navigation.open
|| !self.source_navigation_available()
{
return Task::none();
}
// Only the checked envelope may enter the legacy Close handler.
Message::SourceNavigation(crate::source_navigation::Event::Close)
}
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
return Task::none();
}
message => message,
};
if let Message::SourceSymbols(context, _) = &message
&& (context.session != self.source_workspace.session()
|| context.path != self.source_workspace.active)
{
return Task::none();
}
// Internal results and geometry do not constitute a new user intent.
// Process query completion even behind a modal to release its worker slot;
// its handler independently refuses navigation outside the live editor.
if let Message::SourceSymbolsReady(completion) = message {
return self.source_symbols_ready(completion);
}
if let Message::DiagramUsagesReady(completion) = message {
return self.diagram_usages_ready(completion);
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
) {
self.source_interaction();
}
if let Message::DiagramUsage(event) = message {
return self.diagram_usage_event(event);
}
if let Some(panel) = &self.diagram_usages {
if matches!(&message, Message::Request(Pending::Close(_))) {
self.diagram_usages = None;
return self.update(message);
}
if matches!(&message, Message::Escape) {
return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token));
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
) {
return Task::none();
}
}
if let Some(task) = self.reference_message(&message) {
return task;
}
if matches!(&message, Message::Connection(_))
&& self
.architecture
@@ -1453,7 +1141,7 @@ impl App {
| Message::CanvasResized(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::SourceViewport(_)
| Message::SourceViewport
| Message::Request(Pending::Close(_))
)
{
@@ -1499,7 +1187,7 @@ impl App {
| Message::Exported(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::SourceViewport
)
{
return Task::none();
@@ -1537,7 +1225,7 @@ impl App {
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::SourceViewport
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
@@ -1611,7 +1299,6 @@ impl App {
self.last_workspace_page = self.page;
}
let mut task = self.handle(message);
self.reconcile_reference_chooser();
self.route_state_diagram_workspace();
if self.page != Page::Home {
self.last_workspace_page = self.page;
@@ -1679,23 +1366,8 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
|| self.hosted.visible
|| self.workflow.is_some()
|| self.architecture.is_some()
|| self.model_menu.is_some()
|| self.file_prompt.is_some()
|| self.delete_pending
|| self.pending.is_some()
{
return None;
}
self.behavior.scenario.focus_stamp(self.scenario_context()?)
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::BehaviorControl(intent) => return self.behavior_control(intent),
Message::Architecture(event) => return self.architecture_event(event),
Message::Home(event) => return self.home_event(event),
Message::CheckpointTick => return self.checkpoint_tick(),
@@ -1711,18 +1383,6 @@ impl App {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::FormShown(_, mount) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.scenario_focus_owner().is_some()
&& self.behavior.scenario.take_form_focus(mount)
{
return scenario::focus::task(self.scenario_focus_owner().unwrap());
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
@@ -1747,9 +1407,9 @@ impl App {
return Task::none();
}
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Details may report more than once in a frame. Accept the
// current owner's passive clamps across an epoch bump while
// still rejecting another document, owner or revision.
// Canvas and outline notifications may come from the
// same frame. Accept both across the first epoch bump,
// while still rejecting another owner/document/revision.
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
@@ -1760,49 +1420,6 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.scenario_context().is_some_and(|c| {
c.document == context.document
&& c.scenario == context.scenario
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
}
input => {
if self.behavior.scenario.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.scenario_context() != Some(context) {
return Task::none();
}
@@ -1816,11 +1433,9 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, scale) => {
let mut state = std::mem::take(&mut self.behavior.scenario);
if state.camera_zoom(self.project(), context.scenario, scale) {
if self.behavior.scenario.zoom(scale) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.scenario = state;
}
CanvasEvent::OpenUse(_, occurrence) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
@@ -1911,22 +1526,13 @@ impl App {
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = if tool == scenario::Tool::Lifeline {
state
.update(
self.project(),
scenario::Event::Start(context.scenario, tool),
)
.map(|_| ())
} else {
state.begin_occurrence(
self.project(),
context,
tool,
slot,
lifeline,
)
};
let result = state.begin_occurrence(
self.project(),
context,
tool,
slot,
lifeline,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
@@ -2148,17 +1754,7 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
return Task::none();
}
if self.behavior.source_active() {
self.behavior.acknowledge_commit();
self.status = "Behavior form cancelled; no staged source was created.".into();
} else if self.behavior.creation.has_pending() {
if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
}
@@ -2166,12 +1762,6 @@ impl App {
self.status = "Drawing cancelled · Select / move is active".into();
}
self.source_navigation.open = false;
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
if restore_source_focus {
self.restore_source_editor_focus();
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
@@ -2227,9 +1817,6 @@ impl App {
.insert(path, crate::forms::ValueBrowser { scope, page });
}
}
Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => {
return Task::none();
}
Message::FieldReferenceBrowse(path, query, page) => {
if let Some(draft) = &mut self.draft {
let page = if draft
@@ -2470,7 +2057,7 @@ impl App {
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
match event {
crate::workflow::Event::Semantic(event) => {
state.update(self.project(), *event)
state.update(self.project(), event)
}
crate::workflow::Event::Apply => {
let focus = state.target(self.project());
@@ -2734,13 +2321,10 @@ impl App {
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
return Task::none();
}
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
&& (self.source_dirty() || self.draft_dirty())
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
{
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
return Task::none();
}
let source_was_open = self.behavior.source_open();
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
return Task::none();
@@ -2758,11 +2342,6 @@ impl App {
}
}
Ok(None) => {
if !source_was_open && self.behavior.source_open() {
return iced::widget::operation::focus(
behavior::source_draft::NAME_INPUT,
);
}
if focus_name {
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
}
@@ -2958,19 +2537,13 @@ impl App {
Message::Undo | Message::Redo => {
let redo = matches!(message, Message::Redo);
if self.apply() {
let changed = if redo {
self.document.redo()
if redo {
self.document.redo();
} else {
self.document.undo()
};
if changed {
self.sync();
self.document.undo();
}
self.status = if !changed && redo {
"Nothing to redo"
} else if !changed {
"Nothing to undo"
} else if redo {
self.sync();
self.status = if redo {
"Model change redone"
} else {
"Model change undone"
@@ -3223,21 +2796,12 @@ impl App {
if self.path.is_none()
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.source_workspace.reset_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.disk_project = Some(disk);
}
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
}
Err(error) => {
if self.path.is_none()
|| self.path.as_ref() == Some(&receipt.target)
{
self.document.invalidate_saved_baseline();
}
self.status = format!(
"Disk recovery completed; files could not be reloaded: {error}"
)
@@ -3294,28 +2858,13 @@ impl App {
}
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
Message::SourceSymbolsReady(completion) => {
return self.source_symbols_ready(completion);
}
Message::DiagramUsage(event) => return self.diagram_usage_event(event),
Message::DiagramUsagesReady(completion) => {
return self.diagram_usages_ready(completion);
}
Message::DiagramRevealViewport(context, epoch, size) => {
if self
.pending_diagram_reveal
.as_ref()
.is_some_and(|(c, _, e)| *c == context && *e == epoch)
&& self
.diagram_reveal_viewport(&Message::DiagramRevealViewport(
context, epoch, size,
))
.is_some()
{
return self.handle(Message::CanvasResized(size));
Message::SourceViewport => {
if let Some(cursor) = self.source_jump.take() {
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return iced::widget::operation::focus("source-editor");
}
}
Message::ApplySource => {
@@ -3362,7 +2911,6 @@ impl App {
return Task::none();
}
self.page = Page::Text;
self.source_find_instance = Id::new_v4();
self.source_navigation.open = true;
self.source_navigation.refresh(&self.source_editor.text());
return iced::widget::operation::focus(if go_to_line {
@@ -3371,22 +2919,13 @@ impl App {
"source-find"
});
}
Event::Close => {
let was_open = self.source_navigation.open;
self.source_navigation.open = false;
if was_open {
self.restore_source_editor_focus();
}
}
Event::Close => self.source_navigation.open = false,
Event::Query(query) => {
self.source_navigation.query = query;
self.source_navigation.refresh(&self.source_editor.text());
}
Event::Next(backwards) if self.page == Page::Text => {
self.source_jump = self.source_navigation.next(backwards);
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
if self.source_jump.is_none() {
self.status = "No matching source text.".into();
}
@@ -3397,7 +2936,8 @@ impl App {
.destination(self.source_editor.line_count())
{
Ok(cursor) => {
self.queue_source_reveal(cursor, true);
self.source_jump = Some(cursor);
self.source_select_line = true;
}
Err(error) => self.status = error,
},
@@ -3480,9 +3020,6 @@ impl App {
}
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.source_workspace.reset_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
@@ -3694,52 +3231,6 @@ impl App {
id,
));
}
// Refining a leaf moves its explicit responsibilities to the new leaf.
// Later children inherit only the existing unambiguous performer summary.
if self.create_kind.is_work()
&& let Some(owner) = parent
&& self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
{
let performer = oa_core::validation::allocation_summary(self.project(), owner);
let leaf = !self
.project()
.children(owner)
.any(|e| e.kind() == self.create_kind);
let responsibilities: Vec<_> = self
.project()
.relations
.values()
.filter(|r| {
r.target() == owner
&& matches!(
r.kind(),
RelationKind::Allocation | RelationKind::RoleAssignment
)
})
.collect();
if leaf && !responsibilities.is_empty() {
for original in responsibilities {
let mut responsibility = original.clone();
match &mut responsibility.body {
RelationBody::Allocation { target, .. }
| RelationBody::RoleAssignment { target, .. } => *target = id,
_ => unreachable!(),
}
commands.push(Command::EditRelation(original.id, responsibility));
}
} else if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
performer,
id,
));
}
}
if self.create_kind == Kind::Occurrence
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
&& let Some(steps) = owner.steps()
@@ -3757,13 +3248,19 @@ impl App {
.get(&diagram)
.is_none_or(|d| d.projection.includes(self.create_kind))
{
let mut p = match execute_batch(self.project(), &commands) {
Ok(candidate) => candidate,
Err(error) => {
self.status = error.to_string();
return;
let mut p = self.project().clone();
for command in &commands {
if let Command::CreateDiagram(d) = command {
p.diagrams.insert(d.id, d.clone());
}
};
}
p.elements.insert(
id,
match &commands[0] {
Command::CreateElement(e) => e.clone(),
_ => unreachable!(),
},
);
let owner = if allocated {
Some(self.selected)
} else {
@@ -4081,9 +3578,8 @@ impl App {
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.source_workspace = Workspace::new(&disk.bundle, false);
self.source_workspace.set_reachable(disk.reachable.clone());
self.source_workspace.reachable = disk.reachable.clone();
self.show_source_file(false);
self.source_diagnostics = error.diagnostics().to_vec();
self.source_version += 1;
@@ -4135,13 +3631,11 @@ impl App {
let column = prefix
.rfind('\n')
.map_or(offset, |start| offset - start - 1);
self.queue_source_reveal(
text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
},
true,
);
self.source_jump = Some(text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
});
self.source_select_line = true;
}
fn create_source_file(&mut self) -> Result<(), String> {
@@ -4751,10 +4245,6 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
#[path = "app/save_as_tests.rs"]
mod save_as_tests;
#[cfg(test)]
#[path = "app/repair_save_state_tests.rs"]
mod repair_save_state_tests;
#[cfg(test)]
mod source_project_tests {
use super::*;
@@ -70,7 +70,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -225,7 +225,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -300,7 +299,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
-5
View File
@@ -214,11 +214,6 @@ impl App {
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
-145
View File
@@ -1,145 +0,0 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
| behavior::topology::CanvasEvent::Focus(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
| behavior::scenario::CanvasEvent::Focus(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -1,403 +0,0 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
-445
View File
@@ -1,445 +0,0 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
if let Event::Open(context) = event {
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
iced::widget::operation::focus(filter_id),
]);
}
let token = match &event {
Event::Open(_) => unreachable!(),
Event::Close(t)
| Event::Search(t, _)
| Event::Page(t, _)
| Event::Move(t, _)
| Event::Activate(t)
| Event::Destination(t, _)
| Event::Choose(t, _) => *t,
};
let Some(panel) = &self.diagram_usages else {
return Task::none();
};
if panel.token != token {
return Task::none();
}
if matches!(event, Event::Close(_)) {
self.diagram_usages = None;
if self.page == Page::Text {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
}
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -213,14 +213,6 @@ impl App {
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
@@ -353,14 +345,6 @@ impl App {
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
+35 -79
View File
@@ -15,15 +15,18 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
if let Some(context) = self.topology_context() {
let mut state = std::mem::take(&mut self.behavior.topology);
if state.reveal_selection(self.project(), context.owner, self.selected) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.topology = state;
}
Task::none()
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
@@ -49,47 +52,6 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
input => {
if self.behavior.topology.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
@@ -119,7 +81,6 @@ impl App {
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
@@ -157,18 +118,14 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
let mut state = std::mem::take(&mut self.behavior.topology);
state.camera_zoom(self.project(), context.owner, zoom);
self.behavior.topology = state;
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
@@ -192,9 +149,7 @@ impl App {
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -569,30 +524,32 @@ mod tests {
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
)));
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
let camera = app.behavior.topology.camera_boundary();
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
@@ -600,12 +557,12 @@ mod tests {
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Page,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
@@ -625,7 +582,6 @@ mod tests {
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
assert!(app.behavior.topology.camera_boundary() == camera);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
@@ -1,304 +0,0 @@
//! One transient chooser for the currently mounted Inspector or review field.
//! Browsing never calls a draft setter or semantic planner.
use super::{App, Message, Page, Pending};
use crate::{
forms::{
Draft,
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
},
navigation::InspectorTab,
semantic_review::{self, RowAddress, RowEvent},
workflow,
};
use iced::{Element, Rectangle, Task};
use oa_core::{FacetSelector, Id, Layer};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Host {
Inspector,
Review {
address: Option<RowAddress>,
facet: FacetSelector,
},
}
impl Host {
fn message(&self, event: chooser::Event) -> Message {
match self {
Self::Inspector => Message::FieldReferenceEvent(event),
Self::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceEvent(event),
}))
}
}
}
}
struct Opened {
host: Host,
request: adapter::Open,
document_session: Id,
page: Page,
layer: Layer,
chooser: chooser::State,
}
#[derive(Default)]
pub(super) struct State {
opened: Option<Opened>,
}
impl App {
pub(crate) fn reference_input_active(&self) -> bool {
self.reference_authoring.opened.is_some()
}
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
match host {
Host::Inspector
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
{
self.draft.as_ref()
}
Host::Review { address, facet } => match self.workflow.as_ref()? {
workflow::Workflow::Semantic(state) => {
state.reference_draft(self.project(), *address, facet, path)
}
_ => None,
},
_ => None,
}
}
fn reference_available(&self) -> bool {
!self.busy
&& !self.hosted.visible
&& self.architecture.is_none()
&& self.model_menu.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.pending.is_none()
&& !self.source_dirty()
&& !self.behavior.has_pending()
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
}
fn reference_valid(&self, opened: &Opened) -> bool {
self.reference_available()
&& self.canvas_document == opened.document_session
&& self.page == opened.page
&& self.layer == opened.layer
&& self.document.revision() == opened.chooser.stamp().context.revision
&& self.project().id == opened.request.document
&& self
.reference_draft(&opened.host, &opened.request.path)
.is_some_and(|draft| draft.reference_matches(&opened.request))
}
pub(super) fn reconcile_reference_chooser(&mut self) {
if self
.reference_authoring
.opened
.as_ref()
.is_some_and(|opened| !self.reference_valid(opened))
{
self.reference_authoring
.opened
.as_mut()
.unwrap()
.chooser
.invalidate();
self.reference_authoring.opened = None;
}
}
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
if !self.reference_available() {
return Task::none();
}
let Some(draft) = self.reference_draft(&host, &request.path) else {
return Task::none();
};
let Ok(chooser) =
draft.reference_catalog(self.project(), &request, self.document.revision())
else {
return Task::none();
};
self.reference_authoring.opened = Some(Opened {
host,
request,
document_session: self.canvas_document,
page: self.page,
layer: self.layer,
chooser,
});
Task::none()
}
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
let Some(mut opened) = self.reference_authoring.opened.take() else {
return Task::none();
};
if !self.reference_valid(&opened) {
return Task::none();
}
if opened.host != host {
self.reference_authoring.opened = Some(opened);
return Task::none();
}
match opened.chooser.update(event) {
Outcome::Ignored | Outcome::Browsed => {
self.reference_authoring.opened = Some(opened);
Task::none()
}
Outcome::Closed(reason) => self.reference_focus(
&opened,
match reason {
Close::Escape => Some(0),
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
Close::Outside | Close::WindowChanged => None,
},
),
Outcome::Write { context, value } => {
let intent = adapter::Set {
context,
generation: opened.request.generation,
value,
};
let result = match &opened.host {
Host::Inspector => self
.draft
.as_mut()
.ok_or_else(|| "The property editor closed.".to_owned())
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
Host::Review { address, facet } => match self.workflow.as_mut() {
Some(workflow::Workflow::Semantic(state)) => {
state.set_reference(self.document.project(), *address, facet, intent)
}
_ => Err("The review editor closed.".into()),
},
};
if let Err(error) = result {
self.status = error;
}
// A scoped write rotates field generation. The task targets the
// newly rendered field, while a later structural edit makes it inert.
self.reference_focus(&opened, Some(0))
}
}
}
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
let Some(step) = step else {
return Task::none();
};
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
return Task::none();
};
if draft.reference_session() != opened.request.session {
return Task::none();
}
trigger::focus(
draft.reference_session(),
draft.reference_generation(),
&opened.request.path,
step,
)
}
/// Called before generic modal dispatch: the review owns a modal, and this
/// chooser owns only its active field. All browser events stop here.
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
self.reconcile_reference_chooser();
match message {
Message::FieldReferenceOpen(request) => {
Some(self.open_reference(Host::Inspector, request.clone()))
}
Message::FieldReferenceEvent(event) => {
Some(self.reference_event(Host::Inspector, event.clone()))
}
Message::Workflow(workflow::Event::Semantic(review_event)) => {
let semantic_review::Event::Row {
address,
facet: Some(facet),
event,
} = review_event.as_ref()
else {
return None;
};
let host = Host::Review {
address: *address,
facet: facet.clone(),
};
match event {
RowEvent::ReferenceOpen(request) => {
Some(self.open_reference(host, request.clone()))
}
RowEvent::ReferenceEvent(event) => {
Some(self.reference_event(host, event.clone()))
}
_ => None,
}
}
Message::Escape | Message::Request(Pending::Close(_))
if self.reference_authoring.opened.is_some() =>
{
let opened = self.reference_authoring.opened.as_ref().unwrap();
let host = opened.host.clone();
let event = opened.chooser.event(Action::Close(Close::Escape));
Some(self.reference_event(host, event))
}
// Global shortcuts are also emitted for keys captured by the search
// input. They must not apply drafts, navigate, or undo the model.
Message::Save(_)
| Message::ShortcutUndo(_)
| Message::Request(Pending::Open | Pending::New)
| Message::SourceNavigation(_)
| Message::Page(Page::Guide)
if self.reference_authoring.opened.is_some() =>
{
Some(Task::none())
}
_ => None,
}
}
pub(super) fn reference_overlay<
'a,
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
>(
&'a self,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
let opened = self
.reference_authoring
.opened
.as_ref()
.filter(|opened| self.reference_valid(opened));
let host = opened
.map(|opened| opened.host.clone())
.unwrap_or(Host::Inspector);
let focus_scope = if self.reference_available() {
match self.workflow.as_ref() {
None if self.inspector_tab == InspectorTab::Properties => {
self.draft.as_ref().map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: false,
})
}
Some(workflow::Workflow::Semantic(state)) => state
.reference_form(self.project())
.map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: true,
}),
_ => None,
}
} else {
None
};
chooser::widget::Popup::new(
content,
opened.map(|opened| &opened.chooser),
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
move |event| host.message(event),
)
.focus_scope(focus_scope)
.into()
}
}
#[cfg(test)]
mod tests;
@@ -1,586 +0,0 @@
use super::*;
use crate::forms::Draft;
use oa_core::*;
use serde_json::{Value, json};
const OWNER: Id = Id::from_u128(100);
const BASE: u128 = 10_000;
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
let generated = p.add_element(kind, layer, name, parent).unwrap();
let mut element = p.elements.remove(&generated).unwrap();
element.id = id;
element.alias = format!("object_{}", id.as_u128());
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
*definition = DataTypeDefinition::Record { supertype: None };
}
p.elements.insert(id, element);
}
fn catalog() -> Project {
let mut p = Project::empty();
p.id = Id::from_u128(1);
p.name = "Reference catalog".into();
element(
&mut p,
OWNER,
Kind::DataType,
Layer::Oa,
"Edited record",
None,
);
for n in (0..1_000).rev() {
let id = Id::from_u128(BASE + n);
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
}
element(
&mut p,
Id::from_u128(200),
Kind::DataType,
Layer::Sa,
"Controller",
None,
);
element(
&mut p,
Id::from_u128(201),
Kind::Activity,
Layer::Oa,
"Controller",
None,
);
p
}
fn inspector(p: Project, selected: Id) -> App {
let mut app = App::with_project(p);
let _ = app.update(Message::Select(selected));
app
}
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
app.reference_draft(host, path).unwrap().reference_open(
app.project(),
path,
Rectangle {
x: 790.,
y: 640.,
width: 278.,
height: 32.,
},
)
}
fn open(app: &mut App, host: &Host, path: &str) {
let request = request(app, host, path);
let message = match host {
Host::Inspector => Message::FieldReferenceOpen(request),
Host::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceOpen(request),
}))
}
};
let _ = app.update(message);
assert!(
app.reference_authoring.opened.is_some(),
"field must open: {}",
app.status
);
}
fn event(app: &App, action: Action) -> Message {
let opened = app.reference_authoring.opened.as_ref().unwrap();
opened.host.message(opened.chooser.event(action))
}
fn search(app: &mut App, query: &str) {
let serial = app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.query_serial()
+ 1;
let event = event(
app,
Action::Query {
text: query.into(),
serial,
},
);
let _ = app.update(event);
}
fn choose(app: &mut App, id: Id) {
let event = event(app, Action::Choose(id));
let _ = app.update(event);
}
fn raw(app: &App) -> Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn review(app: &App) -> &semantic_review::State {
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review is open")
};
state
}
fn send_review(app: &mut App, event: RowEvent) {
let addressed = review(app).address_event(event);
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
}
fn review_host(app: &App) -> Host {
let semantic_review::Event::Row {
address,
facet: Some(facet),
..
} = review(app).address_event(RowEvent::Preview)
else {
panic!("Active facet")
};
Host::Review { address, facet }
}
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.as_u128()),
name: "Rationale".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
}
#[test]
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
let p = catalog();
let mut app = inspector(p.clone(), OWNER);
let before = app.document.bundle().clone();
let revision = app.document.revision();
// Malformed escaped text is unrelated to the selected reference. Browsing
// and the scoped write must keep its exact spelling and inactive payload.
app.draft.as_mut().unwrap().set_exact(true).unwrap();
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
let raw_before = raw(&app);
open(&mut app, &Host::Inspector, PATH);
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1_000
);
search(&mut app, "controller @controller_0720 OA");
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
assert_eq!(picker.count(), 1);
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo());
// Captured search shortcuts cannot accidentally save/apply or undo a model.
let _ = app.update(Message::Save(false));
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
choose(&mut app, Id::from_u128(BASE + 720));
let mut expected_raw = raw_before;
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
assert_eq!(raw(&app), expected_raw);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Apply);
assert_eq!(
app.document.bundle(),
&before,
"Unrelated invalid text remains an Apply error"
);
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
let _ = app.update(Message::Apply);
let mut expected = p;
if let Some(SharedBody::DataType { definition, .. }) =
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record {
supertype: Some(Id::from_u128(BASE + 720)),
};
}
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
assert_ne!(accepted, before);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
assert_eq!(app.project(), &expected);
}
#[test]
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
for (raw_id, expected) in [
("-".into(), "invalid"),
(Id::from_u128(999).to_string(), "missing"),
(Id::from_u128(200).to_string(), "incompatible"),
] {
let mut app = inspector(catalog(), OWNER);
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(raw_id))
.unwrap();
let original = raw(&app);
let accepted = app.document.bundle().clone();
open(&mut app, &Host::Inspector, PATH);
let current = &app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.current;
assert!(matches!(
(expected, current),
("invalid", chooser::Current::InvalidRaw(_))
| ("missing", chooser::Current::Missing(_))
| ("incompatible", chooser::Current::Incompatible(_))
));
let _ = app.update(Message::Escape);
assert_eq!(raw(&app), original);
open(&mut app, &Host::Inspector, PATH);
let clear = event(&app, Action::Clear);
let _ = app.update(clear);
assert!(
app.draft
.as_ref()
.unwrap()
.value
.pointer(PATH)
.unwrap()
.is_null()
);
assert_eq!(app.document.bundle(), &accepted);
assert!(!app.document.can_undo());
}
}
#[test]
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
for change in 0..7 {
let mut app = inspector(catalog(), OWNER);
open(&mut app, &Host::Inspector, PATH);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
match change {
0 => {
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
.unwrap();
}
1 => {
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
}
2 => {
app.canvas_document = Id::new_v4();
}
3 => {
app.hosted.visible = true;
}
4 => {
app.draft
.as_mut()
.unwrap()
.set_context(Some(json!({"different":"scope"})));
}
5 => {
let _ = app.update(Message::Command(Command::Rename(
Id::from_u128(BASE + 3),
"Changed model".into(),
)));
}
_ => {
app.source_workspace.edit(
"unfinished source".into(),
iced::widget::text_editor::Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 0 },
selection: None,
},
);
}
}
let expected = raw(&app);
let source = app.document.bundle().clone();
let _ = app.update(delayed);
assert!(app.reference_authoring.opened.is_none());
assert_eq!(raw(&app), expected);
assert_eq!(app.document.bundle(), &source);
}
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let app = inspector(p, Id::from_u128(300));
let draft = app.draft.as_ref().unwrap();
let request = draft.reference_open(
app.project(),
"/baseline/semantic/facets/0/references/0/target",
Rectangle::default(),
);
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
}
#[test]
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
let mut p = Project::empty();
for n in 0..3 {
element(
&mut p,
Id::from_u128(BASE + n),
Kind::ExchangeItem,
Layer::Oa,
"Signal item",
None,
);
}
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Signal {
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
},
};
let mut app = inspector(p, OWNER);
let list = "/body/Oa/Shared/Event/body/Signal/items";
let path = format!("{list}/0");
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
let expected = raw(&app);
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(list),
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldVariant(
"/body/Oa/Shared/Event/body".into(),
json!({"Change":{"expression":"unfinished"}}),
));
let expected = raw(&app);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
assert!(!app.document.can_undo());
}
#[test]
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
{
for mode in 0..3 {
let mut p = Project::empty();
p.id = Id::from_u128(1);
for (id, layer, parent) in [
(100, Layer::Oa, None),
(101, Layer::Sa, None),
(102, Layer::Sa, None),
(103, Layer::Oa, Some(Id::from_u128(100))),
(104, Layer::Sa, Some(Id::from_u128(101))),
] {
element(
&mut p,
Id::from_u128(id),
if parent.is_some() {
Kind::DataField
} else {
Kind::DataType
},
layer,
"Record",
parent,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
let facet = match mode {
0 => FacetSelector::Data(DataFacet::RecordSupertype),
1 => FacetSelector::Data(DataFacet::Constructor),
_ => FacetSelector::Child(KeyPair {
oa: Some(Id::from_u128(103)),
sa: Some(Id::from_u128(104)),
}),
};
let path = if mode == 2 {
"/parent"
} else {
"/body/Sa/Shared/DataType/definition/Record/supertype"
};
let mut app = App::with_project(p.clone());
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
if mode == 2 {
// A paired attachment is a real catalog facet only after explicit
// correspondence; selecting an unstaged synthetic pair is rejected.
for (id, oa) in [(103, true), (104, false)] {
let choice = crate::app::Choice {
id: Id::from_u128(id),
label: format!("Record · @object_{id}"),
};
send_review(
&mut app,
if oa {
RowEvent::ReferenceOa(choice)
} else {
RowEvent::ReferenceSa(choice)
},
);
}
send_review(&mut app, RowEvent::AddReference);
}
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let record = if mode == 2 {
Id::from_u128(104)
} else {
Id::from_u128(101)
};
assert_eq!(
app.reference_draft(&host, path)
.unwrap_or_else(|| {
panic!(
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.original,
record
);
let before = review(&app).recovery_snapshot().unwrap();
let plan = serde_json::to_value(&review(&app).plan).unwrap();
let proposed = review(&app).proposed.clone();
let source = app.document.bundle().clone();
open(&mut app, &host, path);
search(&mut app, "@object_102 SA");
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
assert_eq!(review(&app).proposed, proposed);
assert_eq!(app.document.bundle(), &source);
let old = event(&app, Action::Choose(Id::from_u128(102)));
send_review(
&mut app,
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
);
let switched = review(&app).recovery_snapshot().unwrap();
let _ = app.update(old);
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
send_review(&mut app, RowEvent::Select(facet));
open(&mut app, &host, path);
choose(&mut app, Id::from_u128(102));
let draft = app.reference_draft(&host, path).unwrap();
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
assert_eq!(draft.materialize().unwrap(), expected_record);
assert_eq!(app.document.bundle(), &source);
assert!(
!app.document.can_undo(),
"A choice only stages the active review candidate"
);
}
}
mod mounted;
#[test]
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
let mut p = Project::empty();
for (id, layer) in [
(100, Layer::Oa),
(101, Layer::Sa),
(102, Layer::Sa),
(110, Layer::Oa),
(111, Layer::Sa),
] {
element(
&mut p,
Id::from_u128(id),
Kind::DataType,
layer,
"Record",
None,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
trace(
&mut p,
Id::from_u128(310),
Id::from_u128(110),
Id::from_u128(111),
);
let mut app = App::with_project(p);
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
let old_open = request(&app, &host, path);
open(&mut app, &host, path);
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
for event in [
semantic_review::Event::ChooseTrace(crate::app::Choice {
id: Id::from_u128(310),
label: "Second exact trace".into(),
}),
semantic_review::Event::OpenTrace,
] {
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
}
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
let expected = review(&app).recovery_snapshot().unwrap();
let source = app.document.bundle().clone();
let _ = app.update(old_choice);
let Host::Review { address, facet } = host else {
unreachable!()
};
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Row {
address,
facet: Some(facet),
event: RowEvent::ReferenceOpen(old_open),
},
)));
assert!(app.reference_authoring.opened.is_none());
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -1,910 +0,0 @@
//! Real field + app-root overlay interaction. The null text renderer lets the
//! same retained widget tree exercise focus and geometry without a GPU/window.
use super::*;
use iced::futures::StreamExt;
use iced::{
Element, Event as Input, Point, Size, Theme, Vector,
advanced::{
Layout, Shell, layout,
widget::{Id as WidgetId, Operation, Tree, operation},
},
keyboard, mouse,
widget::{Space, column, container, opaque, scrollable, stack, text_input},
};
use oa_core::transitions::TransitionDirection;
#[derive(Default)]
struct Clipboard;
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
None
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
}
#[derive(Default)]
struct Fields {
values: Vec<(WidgetId, Rectangle, bool)>,
scrolls: Vec<(Rectangle, Vector)>,
}
impl Operation for Fields {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&WidgetId>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if let Some(id) = id {
self.values.push((id.clone(), bounds, state.is_focused()));
}
}
fn scrollable(
&mut self,
_: Option<&WidgetId>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn operation::Scrollable,
) {
self.scrolls.push((bounds, translation));
}
}
impl Fields {
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
self.values
.iter()
.find(|(actual, _, _)| actual == id)
.map(|(_, bounds, focused)| (*bounds, *focused))
.expect("Mounted field identity")
}
}
fn view(
app: &App,
scrolled: bool,
nested: bool,
show_review: bool,
) -> Element<'_, Message, Theme, ()> {
let draft = app.draft.as_ref().unwrap();
let form = column![
text_input("Name", &draft.inputs["/name"].raw)
.id("before-reference")
.on_input(|raw| Message::Field("/name".into(), raw)),
adapter::field(draft, app.project(), PATH),
text_input("Description", &draft.inputs["/description"].raw)
.id("after-reference")
.on_input(|raw| Message::Field("/description".into(), raw)),
]
.spacing(9);
let form = trigger::scope(form, draft.reference_session());
let base: Element<'_, Message, Theme, ()> = if scrolled {
container(
scrollable(column![Space::new().height(500), form])
.id("reference-scroll")
.height(180),
)
.width(300)
.into()
} else {
container(form).width(300).height(iced::Fill).into()
};
let base: Element<'_, Message, Theme, ()> = if nested {
scrollable(column![Space::new().height(400), base])
.id("outer-reference-scroll")
.height(180)
.width(300)
.into()
} else {
base
};
let base = if show_review {
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
Some(workflow::Workflow::Semantic(state)) => {
match state.reference_form(app.project()) {
Some(draft) => {
let mut content = column![];
if let Some(raw) = &state.operation_omission {
content = content.push(
text_input("Required engineering rationale", raw)
.id("review-rationale")
.on_input(|raw| {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::OperationRationale(raw)),
))
}),
);
}
content = content.push(trigger::scope(
adapter::field(draft, app.project(), "/parent").map(|message| {
match message {
Message::FieldReferenceOpen(request) => {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::ReferenceOpen(request)),
))
}
other => other,
}
}),
draft.reference_session(),
));
opaque(container(trigger::modal(content)).width(300).height(180))
}
None => Space::new().into(),
}
}
_ => Space::new().into(),
};
stack![base, review].into()
} else {
base
};
app.reference_overlay(base)
}
struct Mount {
tree: Option<Tree>,
size: Size,
scrolled: bool,
nested: bool,
show_review: bool,
}
impl Default for Mount {
fn default() -> Self {
Self {
tree: None,
size: Size::new(360., 320.),
scrolled: false,
nested: false,
show_review: false,
}
}
}
impl Mount {
fn frame(
&mut self,
app: &App,
events: &[(Input, Option<Point>)],
) -> (Vec<Message>, Fields, Option<Rectangle>) {
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
let viewport = Rectangle::with_size(self.size);
let overlay_node = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
let mut messages = Vec::new();
for (input, point) in events {
let mut shell = Shell::new(&mut messages);
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
if let Some(overlay_node) = &overlay_node {
let mut overlay = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.unwrap();
overlay.as_overlay_mut().update(
input,
Layout::new(overlay_node),
cursor,
&(),
&mut Clipboard,
&mut shell,
);
assert!(shell.is_event_captured());
} else {
view.as_widget_mut().update(
tree,
input,
Layout::new(&node),
cursor,
&(),
&mut Clipboard,
&mut shell,
&viewport,
);
}
}
let mut fields = Fields::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), &mut fields);
(
messages,
fields,
overlay_node.map(|node| node.children()[0].bounds()),
)
}
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
self.frame(
app,
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
),
(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
),
],
)
.0
}
async fn perform(&mut self, app: &App, task: Task<Message>) {
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
return;
};
while let Some(action) = stream.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("Chooser only requests scoped widget focus")
};
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
loop {
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), operation.as_mut());
match operation.finish() {
operation::Outcome::Chain(next) => operation = next,
_ => break,
}
}
}
}
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
for message in messages {
let task = app.update(message);
self.perform(app, task).await;
}
}
}
fn key(key: keyboard::key::Named, shift: bool) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
},
text: None,
repeat: false,
})
}
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
raw.chars()
.map(|character| {
let raw = character.to_string();
(
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(raw.clone().into()),
modified_key: keyboard::Key::Character(raw.clone().into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(raw.into()),
repeat: false,
}),
None,
)
})
.collect()
}
#[tokio::test]
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount::default();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("before-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
assert!(
fields.field(&id).1,
"Tab reaches the real reference trigger"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
mount.dispatch(&mut app, messages).await;
let popup = mount
.frame(&app, &[])
.2
.expect("App root overlay is mounted");
assert!(
popup.x >= 0.
&& popup.y >= 0.
&& popup.x + popup.width <= 360.
&& popup.y + popup.height <= 320.
);
let mut inputs = typing("@controller_0720");
inputs.push((key(keyboard::key::Named::Escape, false), None));
let messages = mount.frame(&app, &inputs).0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&id).1,
"Escape returns to its exact reference field"
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &accepted);
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
.0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&WidgetId::new(expected)).1,
"Chooser Tab stays within the originating form"
);
}
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
{
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let mut mount = Mount::default();
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("@controller_0720")).0;
mount.dispatch(&mut app, messages).await;
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1);
let delayed_focus = app.update(messages.into_iter().next().unwrap());
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(PATH),
Some(&json!(Id::from_u128(BASE + 720)))
);
assert_eq!(app.document.bundle(), &accepted);
// A structural edit occurs after Choose but before its asynchronous focus.
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, Value::Null)
.unwrap();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
mount.perform(&app, delayed_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"A missing old field target makes no focus changes"
);
assert!(
!fields
.field(&trigger::field_id(
app.draft.as_ref().unwrap().reference_generation(),
PATH
))
.1
);
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
let mut app = inspector(catalog(), OWNER);
let mut mount = Mount {
scrolled: true,
..Default::default()
};
mount.frame(&app, &[]);
mount
.perform(
&app,
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
)
.await;
let fields = mount.frame(&app, &[]).1;
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let content = fields.field(&id).0;
let (viewport, offset) = fields.scrolls[0];
assert!(offset.y > 400., "Fixture is genuinely scrolled");
let expected = Rectangle {
x: content.x - offset.x,
y: content.y - offset.y,
..content
}
.intersection(&viewport)
.unwrap();
let messages = mount.click(&app, expected.center());
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Scrolled field should emit one actual Open")
};
assert_eq!(request.anchor, expected);
mount.dispatch(&mut app, messages).await;
let close = app.update(Message::Escape);
mount.perform(&app, close).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Focused scrolled field opens without a pointer")
};
assert_eq!(request.anchor, expected);
}
#[tokio::test]
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
for nested in [false, true] {
let mut app = inspector(catalog(), OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
scrolled: true,
nested,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
assert!(
fields
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(
messages.is_empty(),
"Keyboard reveal must not author any input"
);
let fields = mount.frame(&app, &[]).1;
assert!(fields.field(&WidgetId::new("before-reference")).1);
assert!(
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
"Every enclosing viewport reveals fresh focus"
);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
assert!(
fields.field(&id).0.y - offset >= 180.,
"The next reference starts below the fold"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(messages.is_empty());
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(focused);
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(
messages.len(),
1,
"A newly revealed field opens through Enter"
);
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
mount
.perform(
&app,
iced::widget::operation::snap_to(
"reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
if nested {
mount
.perform(
&app,
iced::widget::operation::snap_to(
"outer-reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
}
assert!(
mount
.frame(&app, &[])
.1
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
}
#[tokio::test]
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
for backwards in [false, true] {
let app = inspector(catalog(), OWNER);
let original = raw(&app);
let mut mount = Mount::default();
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let forward = [
WidgetId::new("before-reference"),
reference.clone(),
WidgetId::new("after-reference"),
];
let reverse = [
WidgetId::new("after-reference"),
reference,
WidgetId::new("before-reference"),
];
let expected = if backwards { &reverse } else { &forward };
for n in 0..7 {
let (messages, fields, popup) =
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
assert!(messages.is_empty() && popup.is_none());
assert!(fields.field(&expected[n % 3]).1);
assert_eq!(
fields
.values
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1
);
}
assert_eq!(raw(&app), original);
assert!(!app.document.can_undo());
}
}
#[tokio::test]
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let source = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount {
show_review: true,
..Default::default()
};
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let session = app.draft.as_ref().unwrap().reference_session();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
assert_eq!(
app.draft.as_ref().unwrap().reference_session(),
session,
"Old Inspector really remains mounted"
);
assert_eq!(
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
id
);
let review_before = review(&app).recovery_snapshot().unwrap();
let review_field = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
!fields.field(&id).1,
"A live old field underneath a modal cannot receive the stale focus task"
);
assert!(fields.field(&WidgetId::new("after-reference")).1);
assert!(!fields.field(&review_field).1);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
assert!(messages.is_empty());
assert!(
fields.field(&review_field).1,
"Review entry ignores retained focus under its modal"
);
assert!(
!fields.field(&WidgetId::new("after-reference")).1,
"Modal focus acquisition deactivates background inputs"
);
let messages = mount.frame(&app, &typing("x")).0;
assert!(
messages.is_empty(),
"A character at the focused Review trigger cannot type into the Inspector below it"
);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
assert!(messages.is_empty());
assert!(fields.field(&review_field).1);
assert!(!fields.field(&id).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let saved = review(&app).recovery_snapshot().unwrap();
let id = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"Fixture retains Inspector focus before the modal receives input"
);
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
assert!(matches!(
&app.reference_authoring.opened.as_ref().unwrap().host,
Host::Review { .. }
));
let fields = mount.frame(&app, &[]).1;
assert!(!fields.field(&WidgetId::new("after-reference")).1);
let messages = mount.click(&app, Point::new(359., 319.));
assert!(matches!(
messages.as_slice(),
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
semantic_review::Event::Row {
event: RowEvent::ReferenceEvent(chooser::Event {
action: Action::Close(Close::Outside),
..
}),
..
}
)
));
for message in messages {
let task = app.update(message);
assert!(
iced_runtime::task::into_stream(task).is_none(),
"Outside dismissal must not request focus or apply"
);
}
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
assert!(messages.is_empty() && popup.is_none());
assert!(!fields.field(&WidgetId::new("after-reference")).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
{
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let sa = p
.elements
.values()
.find(|element| element.alias == "operation_probe_43")
.unwrap()
.id;
let allocation = p
.relations
.values()
.find(|relation| relation.alias == "operation_probe_30")
.unwrap()
.id;
// The boundary fixture deliberately has no selected operation. Author one
// through the validated command path before testing reverse omission.
let mut message = p.elements[&sa].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = message.shared_mut()
else {
panic!("The operation fixture must contain an Exchange call")
};
*operation = Some(OperationUse {
allocation: Some(allocation),
});
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
assert!(matches!(
oa_core::operations::resolve(&p, sa).unwrap(),
oa_core::operations::OperationResolution::Selected(value)
if value.allocation == allocation
));
let inspector_id = Id::new_v4();
element(
&mut p,
inspector_id,
Kind::DataType,
Layer::Oa,
"Inspector record",
None,
);
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
.unwrap();
let oa = p
.add_element(
Kind::Occurrence,
Layer::Oa,
"Operational message",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(oa);
let trace_id = Id::new_v4();
trace(&mut p, trace_id, oa, sa);
let mut app = inspector(p, inspector_id);
let source = app.document.bundle().clone();
let inspector_raw = raw(&app);
let _ = app.update(Message::StartSemanticReview(trace_id));
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Direction(TransitionDirection::SaToOa),
)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
send_review(&mut app, RowEvent::OperationOmission(true));
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
let candidate = serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap_or_else(|| {
panic!(
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.recovery_snapshot(),
)
.unwrap();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("review-rationale")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("Engineering reason")).0;
assert_eq!(
messages.len(),
"Engineering reason".chars().count(),
"Every key reaches the legitimate sibling inside Review"
);
assert!(messages.iter().all(|message| matches!(
message,
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
event: RowEvent::OperationRationale(_),
..
})
)));
mount.dispatch(&mut app, messages).await;
assert_eq!(
review(&app).operation_omission.as_deref(),
Some("Engineering reason")
);
assert!(
mount
.frame(&app, &[])
.1
.field(&WidgetId::new("review-rationale"))
.1
);
assert_eq!(
serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap()
.recovery_snapshot()
)
.unwrap(),
candidate
);
assert_eq!(raw(&app), inspector_raw);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -1,263 +0,0 @@
use super::*;
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn open(app: &mut App, path: &std::path::Path) {
let _ = app.update(Message::Loaded(Ok(Some(
read_snapshot(path.to_owned()).unwrap(),
))));
}
fn edit(app: &mut App, text: &str) {
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text.into())),
)));
}
fn save(app: &mut App, path: &std::path::Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
for extra in [
" include \"missing.arcadia\";\r\n",
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
] {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, &invalid).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
assert!(!app.dirty());
let before = app.document.snapshot();
let revision = app.document.revision();
open(&mut app, &second_path);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
edit(&mut app, VALID);
assert!(app.apply_source(), "{}", app.status);
assert!(app.source_workspace.applied);
assert!(!app.source_dirty());
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.revision(), revision);
assert!(
!app.document.can_undo(),
"Equal content creates no history step"
);
assert!(app.document.dirty());
assert_eq!(app.hosted_label(), "Unsaved changes");
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
let close = window::Id::unique();
let task = app.update(Message::Request(Pending::Close(close)));
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
let _ = app.update(Message::Cancel);
save(&mut app, &second_path);
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
}
}
#[test]
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let before = app.document.snapshot();
let invalid = format!("{VALID} unfinished {{");
std::fs::write(&path, &invalid).unwrap();
open(&mut app, &path);
let repaired = format!("{VALID}// Repaired rationale\r\n");
edit(&mut app, &repaired);
assert!(app.apply_source());
assert!(app.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
app.dirty(),
"Old saved bytes are not saved at the repair destination"
);
let _ = app.update(Message::Redo);
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
std::fs::write(&path, "external edit").unwrap();
let failed = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.clone(),
app.disk_project.clone(),
)
.unwrap_err();
let _ = app.update(Message::Saved(Err(failed)));
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
std::fs::write(&path, &invalid).unwrap();
save(&mut app, &path);
assert!(!app.dirty());
let _ = app.update(Message::Undo);
assert!(app.dirty());
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
}
#[test]
fn valid_equal_content_at_distinct_destinations_stays_clean() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let before = app.document.bundle().clone();
open(&mut app, &second_path);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.path.as_ref(), Some(&second_path));
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
}
#[test]
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let root = second.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let before = VALID.replace("Repair 救援", "Before 救援");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&path, VALID).unwrap();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.before"), &before).unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let accepted = app.document.snapshot();
open(&mut app, &path);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert!(app.apply_source());
assert!(
!app.dirty(),
"Current bytes still match disk before rollback"
);
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
assert!(app.pending_recovery.is_none());
assert!(!journal.exists());
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_dirty());
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
assert_eq!(app.hosted_label(), "Unsaved changes");
save(&mut app, &path);
assert!(!app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
}
#[test]
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
for (current_destination, failed_reload) in
[(true, false), (false, false), (true, true), (false, true)]
{
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let accepted = app.document.snapshot();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
open(&mut app, &path);
assert!(app.apply_source());
assert!(!app.dirty());
let destination = tempfile::tempdir().unwrap();
let saved_elsewhere = destination
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
if !current_destination {
save(&mut app, &saved_elsewhere);
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
}
let mut receipt = recover_snapshot(path.clone()).unwrap();
assert!(
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
"Missing entry reloads as an empty repair workspace"
);
if failed_reload {
// Separately exercise the adapter's successful-recovery/failed-read
// boundary; first-save rollback itself does not cause a read error.
receipt.disk = Err("Injected post-recovery reload failure".into());
}
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
assert!(!path.exists());
assert!(!journal.exists());
assert!(app.pending_recovery.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.dirty(), current_destination);
if current_destination {
let _ = app.update(Message::Request(Pending::New));
assert!(matches!(app.pending, Some(Pending::New)));
} else {
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
}
}
}
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
}
fn row_event(app: &mut App, event: RowEvent) {
let event = state(app).address_event(event);
send(app, Message::Workflow(Event::semantic(event)));
send(app, Message::Workflow(Event::Semantic(event)));
}
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
let mut p = Project::empty();
-507
View File
@@ -1,507 +0,0 @@
//! Current draft navigation, isolated from model application and disk discovery.
use super::*;
use crate::source_navigation::{self as nav, query};
use std::sync::atomic::Ordering;
fn admission_notice(error: query::AdmissionError) -> String {
use nav::symbols::{BuildError, InputProblem, Resource};
let (limit, resource) = match error {
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
language::OutlineResource::SourceBytes => Resource::SourceBytes,
language::OutlineResource::Tokens => Resource::Tokens,
language::OutlineResource::Declarations => Resource::Records,
}),
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
},
};
let unit = match resource {
Resource::Files => "loaded files",
Resource::SourceBytes => "source bytes",
Resource::PathBytes => "bytes in file paths",
Resource::Tokens => "source tokens",
Resource::Records => "declarations and references",
};
format!(
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
)
}
impl App {
pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
}
}
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
revision: self.source_workspace.revision(),
}
}
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
.iter()
.map(|(path, buffer)| query::DraftFile {
path,
text: &buffer.text,
})
.collect();
let accepted = self.document.bundle();
let validated: Vec<_> = accepted
.files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
use_input(query::Input {
key: self.source_key(),
entry: &self.source_workspace.entry,
files: &files,
applied: self.source_workspace.applied,
validation: self
.source_workspace
.applied
.then_some(query::ValidatedSources {
entry: &accepted.entry,
files: &validated,
}),
})
}
fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
snapshot.key() == self.source_key()
&& self.with_source_input(|input| snapshot.matches(input))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
/// Mounted widget IDs change when another interaction supersedes a reveal.
/// A queued focus operation therefore cannot find a subsequently reused editor.
pub fn source_editor_id(&self) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-editor-{}-{}-{}",
self.source_workspace.session(),
self.source_workspace.active,
self.source_symbols.interaction
))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
.as_ref()
.map(|reveal| reveal.ticket)
}
pub(super) fn source_interaction(&mut self) {
self.source_symbols.interaction = Id::new_v4();
self.source_symbols.request = None;
self.source_symbols.reveal = None;
self.source_jump = None;
}
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
self.source_jump = Some(cursor);
self.source_select_line = select_line;
self.source_symbols.reveal = Some(nav::Reveal {
ticket: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
snapshot: self.source_key(),
path: self.source_workspace.active.clone(),
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
};
if Some(reveal.ticket) != ticket
|| reveal.interaction != self.source_symbols.interaction
|| !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
reveal.raw_interaction,
)
|| reveal.snapshot != self.source_key()
|| reveal.path != self.source_workspace.active
|| !self.source_navigation_available()
{
return Task::none();
}
let raw_interaction = reveal.raw_interaction;
self.source_symbols.reveal = None;
if let Some(cursor) = self.source_jump.take() {
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
return Task::none();
}
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
if !self.source_navigation_available() {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
self.source_symbols.open = true;
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
};
if results.stamp != stamp
|| stamp.snapshot != self.source_key()
|| self
.source_symbols
.cache
.as_ref()
.is_none_or(|cache| !self.source_snapshot_matches(cache))
{
self.source_symbols.notice =
Some("Source changed. Run navigation again for current locations.".into());
return Task::none();
}
let destination = if reference {
results
.references
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
} else {
results
.definitions
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
};
if let Some((path, start, end)) = destination {
self.source_symbol_jump(path, start, end);
}
}
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
}
Task::none()
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
) {
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
return Task::none();
}
if self.source_symbols.in_flight.is_some() {
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
return Task::none();
}
let cursor = self.source_editor.cursor();
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
self.source_symbols.notice =
Some("Place the cursor on an alias or its @ reference.".into());
return Task::none();
};
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
}
};
let request = nav::Request {
job: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
stamp: query::QueryStamp {
snapshot: self.source_key(),
serial: self.source_symbols.serial,
},
path: self.source_workspace.active.clone(),
offset,
cursor,
action,
};
let cache = self
.source_symbols
.cache
.as_ref()
.filter(|cache| self.source_snapshot_matches(cache))
.cloned();
let input = if cache.is_none() {
match self
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
{
Ok(input) => Some(input),
Err(error) => {
self.source_symbols.notice = Some(admission_notice(error));
return Task::none();
}
}
} else {
None
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
let snapshot = match cache {
Some(cache) => cache,
None => Arc::new(
input
.expect("admitted input for uncached request")
.capture()
.map_err(admission_notice)?,
),
};
let results = snapshot
.query(query::Query {
stamp: worker_request.stamp,
path: &worker_request.path,
byte_offset: worker_request.offset,
action: worker_request.action,
})
.map_err(|error| match error {
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
})?;
Ok((snapshot, Arc::new(results)))
})
.await
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
let request = completion.request;
if self.source_symbols.in_flight != Some(request.job) {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
&self.source_symbols.raw_interaction,
request.raw_interaction,
)
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
}
}
Ok(_) => {
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
}
Err(error) => self.source_symbols.notice = Some(error),
}
Task::none()
}
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
let cursor = text_editor::Cursor {
position: text_editor::Position {
line: start.line,
column: start.byte_column,
},
selection: Some(text_editor::Position {
line: end.line,
column: end.byte_column,
}),
};
if self
.source_workspace
.files
.get(path)
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
{
self.source_symbols.notice =
Some("That source location is no longer available. Run navigation again.".into());
return;
}
if let Some(buffer) = self
.source_workspace
.files
.get(&self.source_workspace.active)
{
let origin = nav::HistoryEntry {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
.retain(|entry| entry.session == origin.session);
if self.source_symbols.back.len() == 64 {
self.source_symbols.back.remove(0);
}
self.source_symbols.back.push(origin);
}
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
fn source_symbol_back(&mut self) {
while let Some(origin) = self.source_symbols.back.pop() {
if origin.session != self.source_workspace.session() {
continue;
}
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
return;
};
let unchanged = origin.generation == buffer.generation();
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
} else {
buffer.cursor
};
if !nav::exact_cursor(&buffer.text, cursor) {
self.source_symbols.notice = Some(
"The previous location changed. Open the file to choose a current position."
.into(),
);
return;
}
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
return;
}
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,244 +0,0 @@
//! Shared presentation and input contract; contains no model commands.
use crate::app::{App, Message};
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
use iced::{Element, Length::Fill, Point, Theme, Vector};
use oa_core::Id;
mod control;
mod focus;
mod viewport;
pub use control::Control;
pub use viewport::Viewport;
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
!app.busy
&& !app.hosted.visible
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
pub fn controls<'a>(
scope: Scope,
keyboard: bool,
scale: f32,
details: Message,
focus: Option<Message>,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
let message = message.map(|message| intent(scope, name, message));
Control::new(
button(text(label).size(12))
.padding(6)
.on_press_maybe(message.clone()),
scope,
name,
keyboard,
message,
publish,
)
};
row![
control("Details", "Details", Some(details)),
tooltip(control("Canvas", "Canvas", focus),
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(format!("{:.0}%", scale * 100.)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
].spacing(4).align_y(iced::Center).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
impl Scope {
pub(crate) fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
})
} else {
let context = app.topology_context()?;
Some(Self {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
})
}
}
pub(crate) fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
{
Self::current(app)
} else {
None
}
}
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
))
}
}
/// Only a live presentation intent may carry a toolbar action across its own pan.
#[derive(Debug, Clone)]
pub struct Intent {
pub scope: Scope,
pub name: &'static str,
pub message: Box<Message>,
}
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
Message::BehaviorControl(Intent {
scope,
name,
message: Box::new(message),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Focus,
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
Leave,
}
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
if next.x.is_finite() && next.y.is_finite() && next != *pan {
*pan = next;
true
} else {
false
}
}
Event::Zoom { factor, at, base }
if factor.is_finite()
&& factor > 0.
&& base.is_finite()
&& base > 0.
&& at.x.is_finite()
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if old == next {
return false;
}
let anchor = Vector::new(at.x, at.y);
let next_pan = anchor - (anchor - *pan) * (next / old);
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
return false;
}
*pan = next_pan;
*zoom = Some(next);
*fit = false;
true
}
_ => false,
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
keyboard: bool,
publish: impl Fn(Event) -> Message + Copy + 'a,
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> Element<'a, Message> {
let mut tools = column![].spacing(3);
for (glyph, label, selected, message) in entries {
let message = message.map(|message| intent(scope, glyph, message));
tools = tools.push(tooltip(
container(Control::new(
button(text(glyph).size(12))
.width(34)
.height(30)
.padding(3)
.style(if selected {
button::primary
} else {
crate::style::nav
})
.on_press_maybe(message.clone()),
scope,
glyph,
keyboard,
message,
publish,
))
.id(format!("behavior-tool:{glyph}")),
container(text(label).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
));
}
container(scrollable(tools).id(scope.control_id("palette-scroll")))
.width(40)
.height(Fill)
.padding(3)
.style(crate::style::panel)
.into()
}
/// Header and palette are permanent. Details/editors overlay the same viewport.
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
header: Element<'a, Message, Theme, R>,
palette: Element<'a, Message, Theme, R>,
canvas: Element<'a, Message, Theme, R>,
editor: Option<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
let mut body = stack![canvas];
if let Some(editor) = editor {
body = body.push(
container(opaque(
container(editor)
.width(300)
.height(Fill)
.style(crate::style::card),
))
.align_right(Fill)
.height(Fill)
.padding(8),
);
}
column![header, row![body, palette].height(Fill)]
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
pub(crate) mod tests;
@@ -1,256 +0,0 @@
//! A real focusable keyboard target around the existing styled pointer button.
use super::{Event as CameraEvent, Scope, focus::Member};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
#[derive(Default)]
struct Input {
identity: Option<iced::advanced::widget::Id>,
focused: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
}
}
pub struct Control<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
name,
enabled: enabled && activate.is_some(),
activate,
publish: Box::new(publish),
}
}
}
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
identity: Some(self.scope.control_id(self.name)),
focused: false,
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
let identity = self.scope.control_id(self.name);
if state.identity.as_ref() != Some(&identity) || !self.enabled {
*state = Input {
identity: Some(identity),
focused: false,
};
}
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
let id = self.scope.control_id(self.name);
operation.container(Some(&id), layout.bounds());
if self.enabled {
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
if self.enabled {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
repeat,
..
}) = event
&& state.focused
{
use keyboard::{Key, key::Named};
let action = match key.as_ref() {
Key::Named(Named::Tab) => {
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
}
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
Some((self.publish)(CameraEvent::ToggleFocus))
}
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
self.activate.clone()
}
_ => None,
};
if let Some(action) = action {
shell.publish(action);
shell.capture_event();
shell.request_redraw();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.focused = false;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if self.enabled && tree.state.downcast_ref::<Input>().focused {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::INK,
width: 2.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(control: Control<'a, Message, R>) -> Self {
Element::new(control)
}
}
@@ -1,293 +0,0 @@
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
use super::{Event, Scope};
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
pub(super) struct Member(pub Scope);
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
iced::advanced::widget::operate(Collect {
scope,
valid: false,
event,
members: vec![],
focused: None,
palette: None,
})
}
struct Collect {
scope: Scope,
valid: bool,
event: Event,
members: Vec<(Id, Rectangle)>,
focused: Option<usize>,
palette: Option<(Rectangle, Rectangle)>,
}
impl<T: Send + 'static> Operation<T> for Collect {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
if self.valid
&& value
.downcast_ref::<Member>()
.is_some_and(|m| m.0 == self.scope)
&& let Some(id) = id
{
self.members.push((id.clone(), bounds));
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
if value.is_focused() {
self.focused = self
.members
.iter()
.position(|(member, _)| Some(member) == id)
.or(self.focused);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
_: &mut dyn operation::Scrollable,
) {
if id == Some(&self.scope.control_id("palette-scroll")) {
self.palette = Some((bounds, content));
}
}
fn finish(&self) -> operation::Outcome<T> {
let canvas = self.scope.control_id("viewport");
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
// owners expose no members; a delayed request cannot clear their focus.
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
return operation::Outcome::None;
}
let target = match self.event {
Event::Focus => Some(canvas),
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
Event::Traverse(back) => {
let Some(current) = self.focused else {
return operation::Outcome::None;
};
let next = if back {
(current + self.members.len() - 1) % self.members.len()
} else {
(current + 1) % self.members.len()
};
Some(self.members[next].0.clone())
}
_ => return operation::Outcome::None,
};
if target
.as_ref()
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
{
return operation::Outcome::None;
}
let reveal = target.as_ref().and_then(|target| {
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
let (bounds, content) = self.palette?;
// Only the palette has member controls inside this scrollable.
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
(field.center_y() - bounds.y - bounds.height / 2.)
.clamp(0., (content.height - bounds.height).max(0.))
})
});
operation::Outcome::Chain(Box::new(Apply {
scope: self.scope,
valid: false,
target,
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
palette: self.scope.control_id("palette-scroll"),
reveal,
}))
}
}
struct Apply {
scope: Scope,
valid: bool,
target: Option<Id>,
members: Vec<Id>,
palette: Id,
reveal: Option<f32>,
}
impl<T> Operation<T> for Apply {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if !self.valid {
return;
}
if self
.target
.as_ref()
.is_some_and(|target| Some(target) == id)
{
state.focus();
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
state.unfocus();
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& id == Some(&self.palette)
&& let Some(y) = self.reveal
{
state.scroll_to(operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
}
}
}
// Every Collect/Apply pass observes current ownership before touching any field.
struct Owner(Option<Scope>);
struct Root<'a, Message, R: renderer::Renderer> {
owner: Option<Scope>,
content: Element<'a, Message, Theme, R>,
}
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Scope>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
owner,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -1,757 +0,0 @@
use super::*;
use crate::app::{App, Message};
use iced::advanced::{Layout, Shell, layout, widget};
use iced::futures::{
StreamExt,
stream::{BoxStream, SelectAll},
};
use iced::{Rectangle, Size, mouse};
use iced_runtime::{
Action,
user_interface::{Cache, UserInterface},
};
use std::collections::HashMap;
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
// iced_winit drains messages while collecting immediately ready actions. A
// pending Task stream survives until its widget operation sends its result.
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
loop {
match stream.poll_next_unpin(&mut context) {
std::task::Poll::Ready(Some(action)) => ready.push(action),
std::task::Poll::Ready(None) => break,
std::task::Poll::Pending => {
pending.push(stream);
break;
}
}
}
}
}
#[derive(Default)]
pub(crate) struct Geometry {
pub containers: HashMap<widget::Id, Rectangle>,
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
pub text_inputs: Vec<Rectangle>,
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.focused.push((id.cloned(), bounds, state.is_focused()));
}
fn text_input(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
self.text_inputs.push(bounds);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some(id) = id {
self.containers.insert(id.clone(), bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if let Some(id) = id {
self.scrolls.insert(id.clone(), (bounds, offset));
}
}
}
impl Geometry {
pub fn bounds(&self, id: &str) -> Rectangle {
self.containers[&widget::Id::from(id.to_owned())]
}
}
/// Mount the actual App root, production widgets and renderer. Events are
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
/// This deliberately does not seed any private canvas gesture state.
pub(crate) struct Mounted {
pub renderer: iced::Renderer,
cache: Cache,
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
mounted.settle(app).await;
mounted
}
pub fn geometry(&mut self, app: &App) -> Geometry {
let mut geometry = Geometry::default();
self.operate(app, &mut geometry);
geometry
}
/// One operation pass, including every nested overlay, via installed Iced.
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
ui.operate(&self.renderer, operation);
self.cache = ui.into_cache();
}
pub async fn settle(&mut self, app: &mut App) {
for _ in 0..3 {
self.batch(
app,
&[iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))],
mouse::Cursor::Unavailable,
)
.await;
}
}
pub async fn batch(
&mut self,
app: &mut App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let messages = self.input(app, events, cursor);
let mut ready = vec![];
let mut pending = Pending::new();
for message in messages.iter().cloned() {
enqueue(app.update(message), &mut ready, &mut pending);
}
self.drain(app, ready, pending).await;
messages
}
pub fn input(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
events,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
self.cache = ui.into_cache();
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
let mut pending = Pending::new();
enqueue(task, &mut ready, &mut pending);
self.drain(app, ready, pending).await;
}
async fn drain(
&mut self,
app: &mut App,
mut ready: Vec<Action<Message>>,
mut pending: Pending,
) {
loop {
let mut outputs = vec![];
if !ready.is_empty() {
// Build only after all App messages in this cycle have run.
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
for action in ready.drain(..) {
match action {
Action::Output(message) => outputs.push(message),
Action::Widget(mut operation) => {
self.operations += 1;
loop {
ui.operate(&self.renderer, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(next) => operation = next,
widget::operation::Outcome::None
| widget::operation::Outcome::Some(()) => break,
}
}
}
action => {
panic!("Unexpected native action in mounted App harness: {action:?}")
}
}
}
self.cache = ui.into_cache();
}
for message in outputs {
enqueue(app.update(message), &mut ready, &mut pending);
}
if !ready.is_empty() {
continue;
}
let Some(action) = pending.next().await else {
break;
};
ready.push(action);
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
ready.push(action);
}
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
// update initializes the nested overlay layout used by draw.
let mut messages = vec![];
let _ = ui.update(
&[],
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"An empty draw preparation must not drop App messages"
);
ui.draw(
&mut self.renderer,
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
Headless::screenshot(
&mut self.renderer,
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
)
}
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(end),
)
.await
}
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::from(id.to_owned()),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
self.operate(app, &mut operation);
}
}
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard::{self, Key};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
#[test]
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
for scale in [0.05, 0.5, 1., 2.] {
let mut pan = Vector::new(-130., 75.);
let mut zoom = Some(scale);
let mut fit = false;
let at = Point::new(171., 93.);
let world = (at - Point::ORIGIN - pan) / scale;
change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 1.25,
at,
base: scale,
},
);
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
let before = (pan, zoom, fit);
for input in [
Event::Pan(Vector::new(f32::INFINITY, 0.)),
Event::Zoom {
factor: f32::NAN,
at,
base: scale,
},
Event::Zoom {
factor: 0.,
at,
base: scale,
},
] {
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
assert_eq!((pan, zoom, fit), before);
}
}
}
#[test]
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
epoch: 0,
};
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
Space::new().width(Fill).height(Fill),
scope,
1.,
true,
|e| e,
)
.into();
let mut tree = widget::Tree::new(view.as_widget());
let node = view
.as_widget_mut()
.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
)
.move_to(Point::new(30., 80.));
let start = Point::new(90., 160.);
let middle = Point::new(125., 175.);
let end = Point::new(145., 185.);
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: middle },
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(end),
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(Size::new(700., 600.)),
);
}
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
let total = messages
.iter()
.fold(Vector::ZERO, |total, event| match event {
Event::Pan(delta) => total + *delta,
_ => total,
});
assert_eq!(total, end - start);
}
pub(crate) fn scope(app: &App) -> Scope {
if let Some(context) = app.scenario_context() {
Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
}
} else {
let context = app.topology_context().expect("mounted behavior owner");
Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
}
}
}
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
let id = scope(app).control_id(name);
mounted
.geometry(app)
.focused
.iter()
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
}
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
for _ in 0..32 {
if focused(mounted, app, name) {
return;
}
mounted
.batch(
app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
}
panic!("Keyboard traversal did not reach {name}");
}
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
messages.iter().find(|m| matches!(m,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
)).expect("actual nonzero camera delta from a mounted gesture").clone()
}
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await;
}
pub(crate) async fn release_middle(
mounted: &mut Mounted,
app: &mut App,
point: Point,
) -> Vec<Message> {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await
}
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
let width = size.width as usize;
assert_eq!(before.len(), width * size.height as usize * 4);
assert_eq!(after.len(), before.len());
let mut changed = 0;
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
if x as f32 - bounds.x > 2.
&& bounds.x + bounds.width - x as f32 > 2.
&& y as f32 - bounds.y > 2.
&& bounds.y + bounds.height - y as f32 > 2.
{
continue;
}
let start = (y * width + x) * 4;
if before[start..start + 3] != after[start..start + 3] {
changed += 1;
}
}
}
assert!(
changed >= bounds.width.min(bounds.height) as usize,
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
);
}
pub(crate) fn character(value: &str) -> iced::Event {
let mut event = key(iced::keyboard::key::Named::Space);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modified_key,
text,
..
}) = &mut event
{
*key = iced::keyboard::Key::Character(value.into());
*modified_key = key.clone();
*text = Some(value.into());
}
event
}
/// Inspect the action without stripping the lifetime from retained packets.
pub(crate) fn intent_action(message: &Message) -> &Message {
match message {
Message::BehaviorControl(intent) => &intent.message,
message => message,
}
}
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
pub(crate) async fn retained_focus_apply(
mounted: &mut Mounted,
app: &mut App,
) -> Box<dyn widget::Operation> {
let entry = mounted.input(
app,
&[key(iced::keyboard::key::Named::F6)],
mouse::Cursor::Unavailable,
);
assert_eq!(
entry.len(),
1,
"actual initial F6 must emit one canvas focus request"
);
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
.expect("F6 returns a widget Task");
let Some(Action::Widget(mut operation)) = stream.next().await else {
panic!("F6 first action must be Collect");
};
mounted.operate(app, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(apply) => apply,
_ => panic!("Live Collect must produce its guarded Apply pass"),
}
}
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
use iced::keyboard::key::Named;
let geometry = mounted.geometry(app);
let last_bounds = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert_eq!(offset.y, 0.);
assert!(
last_bounds.y >= rail.y + rail.height,
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
);
mounted
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
tab_to(mounted, app, last).await;
let geometry = mounted.geometry(app);
let mut visible = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
visible.x -= offset.x;
visible.y -= offset.y;
assert!(
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
);
let messages = mounted
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(
messages.iter().any(
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
),
"the revealed last tool is keyboard activated"
);
assert!(focused(mounted, app, last));
let on = mounted.pixels(app);
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let off = mounted.pixels(app);
assert_drawn_focus(&off, &on, app.window_size, visible);
}
pub(crate) async fn retained_toolbar_boundaries(
mounted: &mut Mounted,
app: &mut App,
tool: &'static str,
) {
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
let epoch = app.canvas_epoch;
let process = app.behavior.topology.camera_boundary();
let scenario = app.behavior.scenario.camera_boundary();
match boundary {
"Chooser" => {
let draft = app.draft.as_ref().unwrap();
let id = crate::forms::reference_chooser::trigger::field_id(
draft.reference_generation(),
"/parent",
);
let bounds = mounted
.geometry(app)
.focused
.iter()
.find(|(field, _, _)| field.as_ref() == Some(&id))
.expect("real Inspector parent reference trigger")
.1;
let request = draft.reference_open(app.project(), "/parent", bounds);
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(app, task).await;
assert!(app.reference_input_active());
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!app.reference_input_active());
}
"Dirty" => {
let task = app.update(Message::Field(
"/name".into(),
"Unsaved camera boundary".into(),
));
mounted.perform(app, task).await;
assert!(app.draft_dirty());
// Every retained packet is also rejected while the owner is dirty.
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert!(app.draft_dirty());
let task = app.update(Message::RevertProperties);
mounted.perform(app, task).await;
assert!(!app.draft_dirty());
}
"Picker" => {
// Public transitions, with cancellation injected at the external
// picker boundary. Never poll the native dialog future here.
let picker = app.update(Message::Save(true));
if cfg!(target_os = "linux") {
assert!(app.file_prompt.is_some());
mounted.perform(app, picker).await;
let task = app.update(Message::CancelFile);
mounted.perform(app, task).await;
} else {
assert!(app.busy, "Save As enters the real native-picker busy state");
drop(picker);
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
let task = app.update(Message::SavePath(None));
mounted.perform(app, task).await;
assert!(!app.busy);
}
}
_ => unreachable!(),
}
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} changes the presentation lifetime"
);
assert_eq!(
app.canvas_epoch, epoch,
"{boundary} cycle leaves semantic context unchanged"
);
let focus_before = mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
let current_lifetime = app.camera_epoch;
for packet in packets {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} retained intents cannot start a new lifetime"
);
assert!(
app.behavior.topology.camera_boundary() == process,
"{boundary} retained toolbar packet changed process presentation"
);
assert!(
app.behavior.scenario.camera_boundary() == scenario,
"{boundary} retained toolbar packet changed scenario presentation"
);
assert_eq!(
mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
focus_before,
"{boundary} retained toolbar packet stole field focus"
);
}
}
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
mounted
.input(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.into_iter()
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
.expect("actual production toolbar packet")
}
@@ -1,426 +0,0 @@
use super::{Event as CameraEvent, Scope};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
use iced::{keyboard, mouse};
#[derive(Default)]
struct Input {
scope: Option<Scope>,
focused: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
self.pan = None;
self.space = false;
}
}
pub struct Viewport<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
scale: f32,
keyboard: bool,
blank_pan: bool,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
scale: f32,
keyboard: bool,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
scale,
keyboard,
blank_pan: false,
publish: Box::new(publish),
}
}
pub fn pan_background(mut self, enabled: bool) -> Self {
self.blank_pan = enabled;
self
}
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
scope: Some(self.scope),
..Default::default()
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
if state.scope.is_none_or(|s| {
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
}
if state
.scope
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|| !self.keyboard
{
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
if self.keyboard {
let id = self.scope.control_id("viewport");
operation.custom(
Some(&id),
layout.bounds(),
&mut super::focus::Member(self.scope),
);
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
state.bounds = Some(layout.bounds());
if changed_bounds || !self.keyboard {
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
let pointer = match event {
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::ButtonReleased(_)
| mouse::Event::WheelScrolled { .. },
) => state.pointer.or_else(|| cursor.position()),
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().or(state.pointer),
};
let visible = layout.bounds().intersection(viewport);
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
&& cursor.position().is_some();
if !self.keyboard {
state.space = false;
}
let mut cancel = changed_bounds;
let mut camera = None;
let mut capture = false;
match event {
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
state.pan = None;
state.space = false;
state.focused = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
state.pointer = None;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
state.pan = None;
state.space = false;
cancel = true;
}
state.focused = inside && self.keyboard;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
|| (*button == mouse::Button::Left && state.space))
{
state.pan = pointer.map(|p| (*button, p));
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some((button, previous)) = state.pan {
state.pan = Some((button, *position));
camera = Some(CameraEvent::Pan(*position - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
if let Some((held, previous)) = state.pan
&& held == *button
{
state.pan = None;
camera = pointer
.filter(|_| inside)
.map(|p| CameraEvent::Pan(p - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
state.pan = None;
cancel = true;
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
};
if y != 0. {
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
camera = Some(CameraEvent::Zoom {
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
at,
base: self.scale,
});
capture = true;
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F6),
modifiers,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
}
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if state.focused && self.keyboard =>
{
use keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::Space) => {
state.space = true;
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
Key::Named(Named::Tab) => {
camera = Some(CameraEvent::Traverse(modifiers.shift()));
capture = true;
}
Key::Named(Named::Escape) => {
state.pan = None;
state.space = false;
camera = Some(CameraEvent::Leave);
capture = true;
}
Key::Named(key) if modifiers.is_empty() => {
let delta = match key {
Named::ArrowLeft => Some(Vector::new(60., 0.)),
Named::ArrowRight => Some(Vector::new(-60., 0.)),
Named::ArrowUp => Some(Vector::new(0., 60.)),
Named::ArrowDown => Some(Vector::new(0., -60.)),
_ => None,
};
camera = delta.map(CameraEvent::Pan);
capture = camera.is_some();
cancel = capture;
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
{
state.space = false;
}
_ => {}
}
if cancel {
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if let Some(event) = camera {
shell.publish((self.publish)(event));
}
if capture {
shell.capture_event();
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
return;
}
let child_cursor = if inside {
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
// A semantic hit or armed creation tool gets first refusal. In Select,
// an otherwise unused blank press starts the same camera gesture.
if self.blank_pan
&& self.keyboard
&& inside
&& !shell.is_event_captured()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
{
state.pan = pointer.map(|p| (mouse::Button::Left, p));
shell.publish((self.publish)(CameraEvent::CancelGesture));
shell.capture_event();
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
renderer.with_layer(layout.bounds(), |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::TEAL,
width: 1.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if tree.state.downcast_ref::<Input>().pan.is_some() {
mouse::Interaction::Grabbing
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(viewport: Viewport<'a, Message, R>) -> Self {
Element::new(viewport)
}
}
File diff suppressed because it is too large Load Diff
+6 -107
View File
@@ -75,7 +75,6 @@ impl Picker {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Open(Layer),
Choose(Family),
Name(String),
@@ -98,11 +97,6 @@ struct Browser {
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
version: u32,
document: Id,
layer: Layer,
@@ -131,17 +125,6 @@ pub struct Created {
}
impl State {
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
@@ -191,49 +174,6 @@ impl State {
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.family != Some(Family::Machine) {
return Err("This reference does not support staged source creation.".into());
}
if d.document != p.id {
return Err("This behavior draft belongs to another document.".into());
}
let mut next = d.clone();
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Machine { layer: d.layer },
d.invocation,
event,
)? {
next.owner = Some(id);
let preview = next
.source
.preview(p, next.owner)?
.ok_or("New owner preview is missing.")?;
if !eligible(&preview, next.layer, Picker::Owner, id) {
return Err("This participant cannot own the machine.".into());
}
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
match event {
Event::Cancel => {
self.draft = None;
@@ -244,8 +184,6 @@ impl State {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
version: 1,
document: p.id,
layer,
@@ -394,7 +332,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
pub(super) fn reference_label(p: &Project, id: Id) -> String {
fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
@@ -402,15 +340,6 @@ pub(super) fn reference_label(p: &Project, id: Id) -> String {
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
self.source.check_scope(
super::source_draft::Scope::Machine { layer: self.layer },
self.owner,
)?;
let preview = self.source.preview(p, self.owner)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.owner, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
@@ -546,21 +475,6 @@ impl State {
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
if let Some(view) = d.source.view(p) {
return Some(
column![
text(format!(
"State machine name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
view.map(Event::Source)
]
.spacing(6)
.height(Fill)
.into(),
);
}
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
@@ -574,7 +488,7 @@ impl State {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Add work and exchange contributions after creation.",
"Owned by the {} layer root. Involve existing work after creation.",
d.layer
))
.size(12),
@@ -617,17 +531,7 @@ impl State {
}
}
Family::Machine => {
if let Some(label) = d.source.label(d.owner) {
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
}
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
button("Create new owner…")
.on_press(Event::Source(super::source_draft::Event::Open(
d.invocation,
)))
.padding(7),
);
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
@@ -640,9 +544,9 @@ impl State {
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through work and exchanges",
Family::Sequence => "A path through existing work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an explicitly chosen participant",
Family::Machine => "States and modes of an existing participant",
};
fields = fields.push(
button(
@@ -694,9 +598,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
let title = match picker {
Picker::Owner if staged_owner => "Or choose an existing participant",
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
@@ -707,17 +609,14 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
.padding(6)
]
.spacing(5);
if let Some(id) = selected
&& !staged_owner
{
if let Some(id) = selected {
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
@@ -1,148 +0,0 @@
//! Keep behavior field state within one editing session and reset it on replacement.
use iced::advanced::widget::{Operation, Tree, tree};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
use oa_core::Id;
struct Identity(Id);
struct Session<'a, Message, R: renderer::Renderer> {
identity: Id,
content: Element<'a, Message, Theme, R>,
}
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
identity: Id,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Session {
identity,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Identity>()
}
fn state(&self) -> tree::State {
tree::State::new(Identity(self.identity))
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let retained = tree.state.downcast_mut::<Identity>();
if retained.0 != self.identity {
retained.0 = self.identity;
// Iced's keyed column preserves same-length child replacements.
// An editing-session replacement must instead get fresh field state.
tree.children = self.children();
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
+85 -302
View File
@@ -4,10 +4,7 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
pub(crate) mod focus;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
#[cfg(test)]
@@ -100,9 +97,7 @@ pub enum CanvasEvent {
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
FormShown(Context, Id),
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Focus(Context),
Shelf(Context, Shelf),
OpenUse(Context, Id),
@@ -130,7 +125,8 @@ pub enum CanvasEvent {
}
#[derive(Debug, Clone, Copy)]
pub enum ScrollArea {
// The independent Details/outline scroller; viewport motion uses Camera.
Canvas,
// Retained navigation channel, now the independent Selection/outline shelf.
Page,
}
impl CanvasEvent {
@@ -139,9 +135,7 @@ impl CanvasEvent {
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| Self::FormShown(context, _)
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Focus(context)
| Self::Shelf(context, _)
| Self::OpenUse(context, _)
@@ -168,7 +162,6 @@ impl std::fmt::Display for Operator {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Start(Id, Tool),
Cancel,
Submit,
@@ -204,14 +197,11 @@ const VALUE_PAGE_SIZE: usize = 24;
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
form_focus_pending: bool,
canvas_tool: Option<(Id, CanvasTool)>,
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
details_offset: iced::Vector,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<Caller>,
restore: Option<DetailsRestore>,
restore: Option<ViewportRestore>,
presentation: workspace::Presentation,
}
const CALLER_LIMIT: usize = 32;
@@ -221,14 +211,15 @@ pub(crate) struct Caller {
pub occurrence: Id,
target: Id,
tool: CanvasTool,
details_offset: iced::Vector,
offsets: [iced::Vector; 2],
presentation: workspace::Presentation,
}
struct DetailsRestore {
struct ViewportRestore {
document: Id,
owner: Id,
token: Id,
offset: iced::Vector,
offsets: [iced::Vector; 2],
pending: [bool; 2],
}
#[derive(Clone, Copy)]
pub(super) struct ScrollRestore {
@@ -249,13 +240,6 @@ struct CallChoiceContext {
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
#[serde(default)]
source_origin: Option<(Id, oa_core::Element)>,
owner: Id,
parent: Id,
before: Option<Id>,
@@ -288,44 +272,6 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_tool,
self.presentation.focused,
self.presentation.shelf,
self.presentation.viewport,
self.presentation.pan,
self.presentation.zoom,
self.presentation.fit,
)
}
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
self.draft
.as_ref()?
.source
.view(p)
.map(|v| v.map(Event::Source))
}
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
if returned {
self.remount_form();
}
returned
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
@@ -333,38 +279,16 @@ impl State {
focused: self.presentation.focused,
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
}
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
let p = &mut self.presentation;
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
if let Some(next) = zoom {
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: old,
})
} else {
self.zoom(None);
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
}
}
pub(crate) fn focused(&self) -> bool {
self.presentation.focused
}
@@ -393,11 +317,11 @@ impl State {
occurrence,
target,
tool: self.canvas_tool(context.scenario),
details_offset: self.details_offset,
offsets: self.scroll_offsets,
presentation: self.presentation.clone(),
});
self.canvas_tool = Some((target, CanvasTool::Select));
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
Ok(target)
}
@@ -424,30 +348,34 @@ impl State {
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.scroll_offsets = frame.offsets;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.restore = Some(DetailsRestore {
self.restore = Some(ViewportRestore {
document: context.document,
owner: frame.owner,
token: Id::new_v4(),
offset: frame.details_offset,
offsets: frame.offsets,
pending: [true; 2],
});
}
pub(super) fn scroll_restore(
&self,
context: Context,
ScrollArea::Page: ScrollArea,
area: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document && r.owner == context.scenario && r.pending[index]
})?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
token: restore.token,
offset: restore.offset,
offset: restore.offsets[index],
})
}
pub(crate) fn restored(
@@ -457,12 +385,22 @@ impl State {
area: ScrollArea,
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document && r.owner == context.scenario && r.token == token
}) {
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let Some(restore) = self.restore.as_mut().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.token == token
&& r.pending[index]
}) else {
return false;
};
restore.pending[index] = false;
if !restore.pending.contains(&true) {
self.restore = None;
}
self.restore = None;
self.scrolled(area, offset)
}
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
@@ -475,57 +413,28 @@ impl State {
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
let session = Id::new_v4();
Ok(Self {
draft: Some(draft),
form_session: session,
form_mount: session,
form_focus_pending: true,
..Default::default()
})
}
pub(crate) fn form_session(&self) -> Option<Id> {
self.draft.as_ref().map(|_| self.form_session)
}
pub(crate) fn form_mount(&self) -> Option<Id> {
self.draft
.as_ref()
.filter(|d| !d.source.is_open())
.map(|_| self.form_mount)
}
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
context,
self.form_session,
self.form_mount()?,
))
}
fn remount_form(&mut self) {
self.form_mount = Id::new_v4();
self.form_focus_pending = true;
}
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
if self.form_mount() == Some(mount) && self.form_focus_pending {
self.form_focus_pending = false;
true
} else {
false
}
}
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let changed = self.details_offset != offset;
self.details_offset = offset;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
fn scroll_offset(&self) -> iced::Vector {
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
}
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
let key = CallChoiceContext {
document: context.document,
@@ -543,16 +452,8 @@ impl State {
}
cached.as_ref().unwrap().1.clone()
}
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let draft = self.draft.as_ref()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
fn created(command: &Command, parent: Id) -> Option<Id> {
match command {
Command::CreateElement(element) if element.parent == Some(parent) => {
@@ -667,66 +568,6 @@ impl State {
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
self.remount_form();
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.tool != Tool::Lifeline {
return Err("This reference does not support staged source creation.".into());
}
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
*document != p.id || p.elements.get(&d.owner) != Some(owner)
}) {
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
}
if d.source_origin.is_none() && d.source.engaged() {
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
}
let mut next = d.clone();
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
{
next.source_origin = Some((
p.id,
p.elements
.get(&d.owner)
.ok_or("The consumer owner no longer exists.")?
.clone(),
));
}
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Lifeline {
owner: d.owner,
viewpoint: viewpoint(p, d.owner),
},
d.invocation,
event,
)? {
next.target = Some(id);
next.command(p)?;
}
let returned = d.source.is_open() && !next.source.is_open();
self.draft = Some(next);
if returned {
self.remount_form();
}
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
@@ -735,13 +576,7 @@ impl State {
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
return Err("Choose a scenario.".into());
}
self.form_session = Id::new_v4();
self.form_mount = self.form_session;
self.form_focus_pending = true;
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: Some((p.id, p.elements[&owner].clone())),
owner,
parent: owner,
before: None,
@@ -1105,14 +940,7 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
}
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
let Some(owner) = p
.elements
.get(&d.owner)
.filter(|e| e.kind() == Kind::Scenario)
else {
return vec![];
};
let layer = owner.layer();
let layer = p.elements[&d.owner].layer();
let mut values: Vec<_> = p
.elements
.values()
@@ -1428,28 +1256,6 @@ impl Draft {
e
}
fn command(&self, p: &Project) -> Result<Command, String> {
if self.source.engaged()
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
*document != p.id || p.elements.get(&self.owner) != Some(owner)
})
{
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
}
self.source.check_scope(
super::source_draft::Scope::Lifeline {
owner: self.owner,
viewpoint: viewpoint(p, self.owner),
},
self.target,
)?;
let preview = self.source.preview(p, self.target)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.target, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
}
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
return Err("Choose this scenario or one of its operands as destination.".into());
}
@@ -1460,8 +1266,6 @@ impl Draft {
.unwrap_or(self.tool.label());
let name = if self.name.trim().is_empty() {
default
} else if self.source.label(self.target).is_some() {
self.name.as_str()
} else {
self.name.trim()
};
@@ -2053,31 +1857,20 @@ pub(crate) mod direct_tests {
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
});
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
});
navigation_event(&mut app, |ctx| {
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
assert_eq!(app.selected, b);
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
});
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
assert_eq!(app.selected, c);
@@ -2088,7 +1881,7 @@ pub(crate) mod direct_tests {
CanvasTool::Message(MessageStyle::Timer)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(0., 115.)
);
navigation_event(&mut app, CanvasEvent::Back);
@@ -2099,11 +1892,11 @@ pub(crate) mod direct_tests {
CanvasTool::Add(Tool::Reuse)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 170.)
);
assert_eq!(
app.behavior.scenario.details_offset,
app.behavior.scenario.scroll_offsets[1],
iced::Vector::new(0., 80.),
"The caller's independent outline scroll is restored separately"
);
@@ -2281,24 +2074,22 @@ pub(crate) mod direct_tests {
let before = app.document.bundle().clone();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Add(tool), true));
scene.camera_pan = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(
Point::new(20., 40.),
Size::new(scene.size.width + 46., scene.size.height + 160.),
);
scene.scroll_offset = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
let x = if on_lifeline {
scene.lanes[&f.sender]
} else {
scene.size.width - 35.
};
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(point));
let point = Point::new(bounds.x + x, bounds.y + y);
let mut input = SequenceInput::default();
canvas_mouse(
&scene,
&mut input,
mouse::Event::CursorMoved { position: point },
mouse::Event::CursorMoved {
position: point - scene.scroll_offset,
},
point,
bounds,
);
@@ -2474,10 +2265,10 @@ pub(crate) mod direct_tests {
assert!(!app.behavior.has_pending());
}
let old_context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
old_context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
ScrollArea::Canvas,
iced::Vector::new(0., 100.),
)));
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
assert!(!app.behavior.has_pending());
@@ -2668,29 +2459,26 @@ pub(crate) mod direct_tests {
);
}
#[test]
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
let f = fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
)));
let context = app.scenario_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
(ScrollArea::Page, iced::Vector::new(0., 40.)),
] {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
assert!(app.busy);
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
assert_ne!(
app.scenario_context(),
@@ -2701,14 +2489,14 @@ pub(crate) mod direct_tests {
stale.document = Id::new_v4();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
ScrollArea::Page,
ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
let root = std::path::PathBuf::from("scenario-details-save-fixture");
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
let receipt = crate::app::FileSnapshot {
document: context.document,
revision: context.revision,
@@ -2729,17 +2517,12 @@ pub(crate) mod direct_tests {
let context = app.scenario_context().unwrap();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
assert_eq!(
app.behavior.scenario.presentation.pan,
iced::Vector::new(-25., -100.)
);
scene.camera_pan = app.behavior.scenario.presentation.pan;
scene.scroll_offset = app.behavior.scenario.scroll_offset();
let slot = scene.slots.last().unwrap().slot;
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
let y = bounds.y + scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
let mut input = super::super::SequenceInput::default();
let mut published = vec![];
for event in [
@@ -2753,7 +2536,7 @@ pub(crate) mod direct_tests {
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(b),
mouse::Cursor::Available(b + scene.scroll_offset),
)
.and_then(|action| action.into_inner().0)
{
@@ -1,334 +0,0 @@
//! A delayed focus request must still own the live parent tree when executed.
//! Collect first: a missing target must not unfocus unrelated fields.
use super::Context;
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
use oa_core::Id as ModelId;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
}
impl Stamp {
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
session,
mount,
}
}
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.revision,
stamp.session,
stamp.mount
))
}
struct Owner(Option<Stamp>);
struct Root<'a, Message, R: renderer::Renderer> {
content: Element<'a, Message, Theme, R>,
owner: Option<Stamp>,
}
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Stamp>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
content: content.into(),
owner,
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
op: &mut dyn Operation,
) {
// This is computed from the current App view, never retained widget state.
op.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, op);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[derive(Clone)]
struct Scroll {
index: usize,
bounds: Rectangle,
capacity: Vector,
offset: Vector,
}
struct Collect {
stamp: Stamp,
valid: bool,
translation: Vector,
scrolls: Vec<Scroll>,
pending: Option<Scroll>,
index: usize,
target: Option<(Rectangle, Vec<Scroll>)>,
}
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
iced::advanced::widget::operate(Collect {
stamp,
valid: false,
translation: Vector::ZERO,
scrolls: vec![],
pending: None,
index: 0,
target: None,
})
.discard()
}
impl Operation for Collect {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
let previous = (self.translation, self.scrolls.len());
if let Some(scroll) = self.pending.take() {
self.translation += scroll.offset;
self.scrolls.push(scroll);
}
visit(self);
self.translation = previous.0;
self.scrolls.truncate(previous.1);
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn operation::Scrollable,
) {
self.pending = Some(Scroll {
index: self.index,
bounds: translated(bounds, self.translation),
capacity: Vector::new(
(content.width - bounds.width).max(0.),
(content.height - bounds.height).max(0.),
),
offset,
});
self.index += 1;
}
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
if id == Some(&name_id(self.stamp)) {
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
}
}
fn finish(&self) -> operation::Outcome<()> {
if !self.valid {
return operation::Outcome::None;
}
let Some((mut bounds, ancestors)) = self.target.clone() else {
return operation::Outcome::None;
};
let mut shifts = BTreeMap::new();
for ancestor in ancestors.iter().rev() {
let dx = reveal(
bounds.x,
bounds.width,
ancestor.bounds.x,
ancestor.bounds.width,
);
let dy = reveal(
bounds.y,
bounds.height,
ancestor.bounds.y,
ancestor.bounds.height,
);
// Drawing offsets are rounded; an already-visible axis must not
// acquire a scroll delta from a fractional capacity alone.
let delta = Vector::new(
if dx == 0. {
0.
} else {
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
},
if dy == 0. {
0.
} else {
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
},
);
if delta != Vector::ZERO {
shifts.insert(ancestor.index, delta);
}
bounds = translated(bounds, delta);
}
operation::Outcome::Chain(Box::new(Apply {
stamp: self.stamp,
valid: false,
shifts,
index: 0,
}))
}
}
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
Rectangle {
x: bounds.x - delta.x,
y: bounds.y - delta.y,
..bounds
}
}
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
if start < viewport || length > size {
start - viewport
} else {
(start + length - viewport - size).max(0.)
}
}
struct Apply {
stamp: Stamp,
valid: bool,
shifts: BTreeMap<usize, Vector>,
index: usize,
}
impl Operation for Apply {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if self.valid {
if id == Some(&name_id(self.stamp)) {
state.focus();
} else {
state.unfocus();
}
}
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& let Some(delta) = self.shifts.get(&self.index)
{
state.scroll_by(
operation::scrollable::AbsoluteOffset {
x: delta.x,
y: delta.y,
},
bounds,
content,
);
}
self.index += 1;
}
}
@@ -1,207 +0,0 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
result
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
}
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -1,276 +0,0 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
if !steps.contains(&id) {
steps.push(id);
}
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,6 @@
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
pub(super) mod render;
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
@@ -1,392 +0,0 @@
//! Scene bridge for local lifetime geometry; no authoring or model mutations.
use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
use crate::{behavior::Line, style, views::short};
use iced::{
Color, Point, Rectangle, Size,
widget::canvas::{self, Frame, Path, Stroke, Text},
};
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
struct EventMarker {
event: Marker,
lifeline: Id,
center: Point,
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub(in crate::behavior) struct Rendering {
// Complete rendering positions, including warning-admitted unassigned lanes.
// Scene.lanes independently retains the existing authoring eligibility rule.
lanes: BTreeMap<Id, f32>,
span: Option<Span>,
lifetimes: Vec<LaneLifetime>,
markers: Vec<EventMarker>,
}
impl Rendering {
pub(in crate::behavior) fn new(
project: &Project,
owner: Id,
lanes: &BTreeMap<Id, f32>,
rows: &BTreeMap<Id, (Id, f32)>,
insertion_scopes: &BTreeMap<Id, (f32, f32)>,
height: f32,
) -> Self {
let span = Span {
start: 85.,
end: height - 20.,
};
if !span.valid() {
return Self::default();
}
// Insertion scopes deliberately start later and end earlier. Empty and
// Create-only scenarios still have the full visible lifetime extent.
let mut scopes = insertion_scopes.clone();
for (scope, (start, end)) in &mut scopes {
if *scope != owner && *start == *end && start.is_finite() {
// An empty operand has 42 px between its insertion axis and
// frame end. Match the existing 26 px open-activation allowance.
*end = *start + 26.;
}
}
scopes.insert(owner, (span.start, span.end));
let lifetimes = project_lifetimes(
project,
owner,
&Geometry {
rows,
scopes: &scopes,
},
);
let lanes: BTreeMap<_, _> = lanes
.iter()
.filter(|(_, x)| x.is_finite())
.map(|(id, x)| (*id, *x))
.collect();
let mut markers = Vec::new();
for lane in &lifetimes {
let Some(&x) = lanes.get(&lane.lifeline) else {
continue;
};
for event in &lane.markers {
let sender_x = project
.elements
.get(&event.occurrence)
.and_then(|e| e.shared())
.and_then(|body| match body {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender: Some(sender),
..
},
..
} => lanes.get(sender).copied(),
_ => None,
});
markers.push(EventMarker {
event: *event,
lifeline: lane.lifeline,
center: Point::new(x, event.y),
capsule: Rectangle::new(
Point::new(x - 70., event.y - 14.),
Size::new(140., 28.),
),
name: project.named(lane.lifeline).to_owned(),
sender_x,
});
}
}
Self {
lanes,
span: Some(span),
lifetimes,
markers,
}
}
pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
for marker in &self.markers {
let Some(sender) = marker.sender_x else {
continue;
};
let Some(line) = lines
.iter_mut()
.find(|line| line.id == marker.event.occurrence)
else {
continue;
};
let x = marker.center.x;
let y = marker.center.y;
let self_message = sender == x;
let end_x = match marker.event.kind {
MarkerKind::Birth if sender < x => marker.capsule.x,
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
MarkerKind::Death => x,
};
line.points = if self_message {
// The receiver is on the recorded occurrence row, including self
// lifecycle events. The elbow stays outside the birth capsule.
let elbow = x + 112.;
line.label_at = Point::new(x + 12., y - 52.);
vec![
Point::new(x, y - 24.),
Point::new(elbow, y - 24.),
Point::new(elbow, y),
Point::new(end_x, y),
]
} else {
if marker.event.kind == MarkerKind::Birth {
// Keep the label above the capsule, including right-to-left
// messages whose existing label starts beside the receiver.
line.label_at.y = y - 40.;
}
vec![Point::new(sender, y), Point::new(end_x, y)]
};
}
}
pub(in crate::behavior) fn alive_spans(
&self,
lifeline: Id,
scope: Id,
top: f32,
bottom: f32,
) -> Vec<Span> {
if !top.is_finite() || !bottom.is_finite() || bottom <= top {
return vec![];
}
self.lifetimes
.iter()
.find(|lane| lane.lifeline == lifeline)
.into_iter()
.flat_map(|lane| &lane.intervals)
.filter(|interval| interval.scope == scope && interval.life == Life::Alive)
.filter_map(|interval| {
let start = top.max(interval.span.start);
let end = bottom.min(interval.span.end);
(start < end).then_some(Span { start, end })
})
.collect()
}
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
self.markers
.iter()
.rev()
.find(|marker| match marker.event.kind {
MarkerKind::Birth => marker.capsule.contains(point),
MarkerKind::Death => marker_distance(marker, point) <= 1.25,
})
.map(|marker| marker.event.occurrence)
}
pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
// Exact visible shapes are tested first by Scene. A low-zoom tolerance
// must not outrank another marker's actual capsule, a header, or a label.
self.markers
.iter()
.map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
}
pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
let span = self.span?;
if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
return None;
}
self.lanes
.iter()
.filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
.map(|(id, _)| *id)
}
pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
let Some(span) = self.span else {
return;
};
for (&id, &x) in &self.lanes {
frame.stroke(
&Path::line(Point::new(x, span.start), Point::new(x, span.end)),
Stroke {
line_dash: canvas::LineDash {
segments: &[2., 5.],
offset: 0,
},
..Stroke::default()
.with_width(1.)
.with_color(if id == selected {
Color::from_rgb8(148, 190, 187)
} else {
Color::from_rgb8(214, 222, 222)
})
},
);
}
for lifetime in &self.lifetimes {
let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
continue;
};
for interval in &lifetime.intervals {
match interval.life {
Life::Alive => frame.stroke(
&Path::line(
Point::new(x, interval.span.start),
Point::new(x, interval.span.end),
),
Stroke::default().with_width(2.).with_color(
if lifetime.lifeline == selected {
style::TEAL
} else {
Color::from_rgb8(91, 113, 113)
},
),
),
Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
frame.fill_text(Text {
content: "?".into(),
position: Point::new(x + 9., interval.span.start + 2.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
_ => {}
}
}
}
if !self.lanes.is_empty() {
frame.fill_text(Text {
content:
"Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
.into(),
position: Point::new(28., span.end + 4.),
size: 10.into(),
color: style::MUTED,
..Default::default()
});
}
}
pub(in crate::behavior) fn draw_markers(
&self,
frame: &mut Frame,
selected: Id,
scale: f32,
pan: iced::Vector,
) {
for marker in &self.markers {
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
style::TEAL
} else {
style::INK
};
match marker.event.kind {
MarkerKind::Birth => {
let path = Path::rounded_rectangle(
marker.capsule.position(),
marker.capsule.size(),
6.into(),
);
frame.fill(&path, Color::from_rgb8(243, 248, 248));
frame.stroke(
&path,
Stroke::default().with_color(color).with_width(
if selected == marker.event.occurrence {
2.5
} else {
1.5
},
),
);
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
// WGPU clipped frames start with an identity transform;
// neither the parent's pan/scale nor its clip is inherited.
frame.with_clip(clip, |label| {
label.translate(pan);
label.scale(scale);
label.fill_text(Text {
content: short(&marker.name, 18),
position: marker.capsule.position() + iced::Vector::new(8., 6.),
size: 12.into(),
color,
..Default::default()
});
});
}
}
MarkerKind::Death => {
for (a, b) in death_strokes(marker.center) {
frame.stroke(
&Path::line(a, b),
Stroke::default().with_color(color).with_width(2.5),
);
}
}
}
}
}
}
fn birth_label_clip(
pan: iced::Vector,
capsule: Rectangle,
scale: f32,
viewport: Size,
) -> Option<Rectangle> {
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
return None;
}
Rectangle::new(
Point::new(
pan.x + (capsule.x + 8.) * scale,
pan.y + (capsule.y + 4.) * scale,
),
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
)
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
}
fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
match marker.event.kind {
MarkerKind::Birth => {
let dx = (marker.capsule.x - point.x)
.max(point.x - marker.capsule.x - marker.capsule.width)
.max(0.);
let dy = (marker.capsule.y - point.y)
.max(point.y - marker.capsule.y - marker.capsule.height)
.max(0.);
dx.hypot(dy)
}
MarkerKind::Death => death_strokes(marker.center)
.iter()
.map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
.fold(f32::INFINITY, f32::min),
}
}
fn death_strokes(center: Point) -> [(Point, Point); 2] {
[
(
center + iced::Vector::new(-7., -7.),
center + iced::Vector::new(7., 7.),
),
(
center + iced::Vector::new(-7., 7.),
center + iced::Vector::new(7., -7.),
),
]
}
#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;

Some files were not shown because too many files have changed in this diff Show More