Compare commits

..
Author SHA1 Message Date
Codex-ytr83r b10b28cefd Keep the legacy sequence helper in test builds only 2026-09-13 06:27:57 -07:00
Codex-ytr83r 5d4a6a47ce Follow inspected Saved scenario through the actual arrangement picker 2026-09-13 06:18:34 -07:00
Codex-ytr83r 8bf7607a3f Allow Details properties for the exact displayed Saved scenario view 2026-09-13 04:02:31 -07:00
Codex-ytr83r ef1c1be704 Restore Scenario inspector from focused Details with stamped admission 2026-09-13 03:42:37 -07:00
Codex-ytr83r 0409a481c7 Correct saved gesture fixtures and preserve explicit cancellation assertions 2026-09-13 02:55:57 -07:00
Codex-ytr83r 916ec5c78b Test exact and fractional scenario spacing input separately 2026-09-13 02:54:23 -07:00
sorokin.oleg.edu c4a2cf63f1 Sync agent instructions from main 2026-09-13 11:40:30 +03:00
Codex-ytr83r 07e611fb94 feat: preview and commit Saved Scenario spacing gestures 2026-09-13 01:32:42 -07:00
Codex-ytr83r 46ff127373 test: enter Scenario fixtures through live navigation 2026-09-13 01:20:02 -07:00
Codex-ytr83r 86a187ab4c Correct mounted scenario camera and palette assertions 2026-09-13 00:38:39 -07:00
Codex-ytr83r 28b7dc5b30 Complete canvas scope wiring and use Tokio for mounted Scenario test 2026-09-12 23:48:07 -07:00
Codex-ytr83r 457a2c28db Prototype pure saved Scenario spacing command previews 2026-09-12 23:46:57 -07:00
Codex-ytr83r 2c9cd2b3e6 Simplify Scenario geometry admission conditions for strict lint 2026-09-12 23:09:38 -07:00
Codex-ytr83r 46e106c04e Wire saved Scenario arrangements through workspace navigation 2026-09-12 23:00:52 -07:00
Codex-ytr83r aa62f42b81 Grow Scenario frames with preserved descendant padding 2026-09-12 22:12:40 -07:00
Codex-ytr83r 7f192f5dc4 Prepare complete saved Scenario display fallback adapter 2026-09-12 22:11:36 -07:00
Codex-ytr83r 68a81c3f50 Resolve saved Scenario geometry through explicit complete Scene plans 2026-09-12 21:26:33 -07:00
Codex-ytr83r 45a7fde2c1 Record spacing gesture candidate and explicit reorder usability gate 2026-09-12 21:11:32 -07:00
Codex-ytr83r bfb4b39744 Exercise saved Scenario positions through HTTP transactions and restart 2026-09-12 21:06:35 -07:00
Codex-ytr83r 20acd3238e Assert complete typed activations in authoring and offset regressions 2026-09-12 21:05:11 -07:00
Codex-ytr83r 695685a2b1 Integrate typed Scenario activations before saved geometry resolution 2026-09-12 20:52:51 -07:00
Codex-ytr83r e4e87951d7 test: read decoded model from restored source snapshot 2026-09-12 20:52:05 -07:00
Codex-ytr83r aed07176ab test: use participant viewpoints for Scenario layout fixtures 2026-09-12 19:49:18 -07:00
Codex-ytr83r 9245f8d23e fix: preserve migration context and reject unsupported SVG exports 2026-09-12 19:41:48 -07:00
Codex-ytr83r a4d1efc5f7 experiment: reserve label ink space inside the fixed corridor 2026-09-12 19:41:29 -07:00
Codex-ytr83r ecc49e0417 feat(core): prepare typed Scenario layout and source lifecycle 2026-09-12 19:27:49 -07:00
Codex-ytr83r e409107a63 experiment: prepare real WGPU Scenario label evaluator 2026-09-12 19:16:43 -07:00
Codex-ytr83r 2f90cf25d1 Verify conservative corridor rounding and stale font rejection 2026-09-12 18:49:11 -07:00
Codex-ytr83r 345f7b3d5b Keep reconstructed label corridors finite and inside blockers 2026-09-12 18:46:13 -07:00
Codex-ytr83r 6a8650669f Record passing measured label and corridor experiments 2026-09-12 18:42:10 -07:00
Codex-ytr83r 903d85dda4 Resolve label tests explicitly for isolated evaluator 2026-09-12 18:36:53 -07:00
Codex-ytr83r 9b56a337f8 Prepare measured scenario labels and clear corridor evaluators 2026-09-12 18:35:52 -07:00
Codex-ytr83r e811879857 Specify candidate saved scenario layout and source lifecycle 2026-09-12 18:29:24 -07:00
Codex-ytr83r 75cb09dade Define scenario layout identity and persistence direction 2026-09-12 18:02:38 -07:00
Codex-ytr83r 208268c8aa Combine Text alias rename with guarded diagram navigation 2026-09-12 17:27:08 -07:00
Codex-ytr83r 4db1ef543c Preserve original Windows test evidence bytes 2026-09-12 16:09:18 -07:00
Codex-ytr83r 0eca66f669 Explain raw test log whitespace preservation 2026-09-12 15:58:25 -07:00
Codex-ytr83r 2488ee5955 Record passing Windows diagram navigation tests 2026-09-12 15:50:15 -07:00
Codex-ytr83r 46b4f3c0d0 Preserve exact diagram usage identity and separate visual focus 2026-09-12 15:10:33 -07:00
Codex-ytr83r f23615ffeb Record layout policy experiment and reproduced history falsifier 2026-09-12 07:27:06 -07:00
Codex-ytr83r fdf756e6a4 Publish in-progress navigation source and independent canvas reviews 2026-09-12 06:58:08 -07:00
Codex-ytr83r 7aa007b077 Publish verified canvas and repair Windows prerelease checkpoint 2026-09-12 05:06:18 -07:00
Codex-ytr83r 0c53c21030 Record verified Windows canvas and repair package acceptance 2026-09-12 04:59:48 -07:00
Codex-ytr83r 4dd0f60c75 Record complete combined Windows gates and bounded native camera checks 2026-09-12 04:26:03 -07:00
Codex-ytr83r 60f714853f Merge commit '51846f5a2b44ae31f8e8f1127fc803f2f430fbe7' into codex/canvas-union-ytr83r-20260911 2026-09-12 04:06:16 -07:00
Codex-ytr83r 5511755a5e Preserve closed native and Unicode verification with repair integration review 2026-09-12 04:02:45 -07:00
Codex-ytr83r 33e30c1cd7 Keep repaired destinations unsaved until their accepted source is written 2026-09-12 03:55:42 -07:00
sorokin.oleg.edu 51846f5a2b Sync agent instructions from main 2026-09-12 13:43:20 +03:00
sorokin.oleg.edu 436ea4b29a Sync agent instructions from main 2026-09-12 13:40:03 +03:00
sorokin.oleg.edu 46c8783c72 Sync agent instructions from main 2026-09-12 13:15:35 +03:00
Codex-ytr83r 0d39d58ebd Record reviewed grapheme fix and closed packaged GUI evidence 2026-09-11 14:09:38 -07:00
Codex-ytr83r cb91525cfd Preserve grapheme clusters when shortening desktop labels 2026-09-11 13:48:21 -07:00
Codex-ytr83r 108b1dd208 Record combined Windows gates and native child-work evidence 2026-09-11 13:34:47 -07:00
Codex-ytr83r a57c334b30 Integrate persistent canvas controls with scenario lifetime rendering 2026-09-11 12:44:06 -07:00
Codex-ytr83r 32334c6631 Record Windows gates and partial native child-work checks 2026-09-11 12:13:33 -07:00
Codex-ytr83r 09da0d90aa Add reproducible native child-work verification fixtures 2026-09-11 11:32:15 -07:00
Codex-ytr83r 865483b4c6 Preserve responsibility assignments when creating child work 2026-09-11 11:04:12 -07:00
Codex-ytr83r f796a27277 Record integrated Windows tests and HTTP persistence checks 2026-09-11 11:04:09 -07:00
Codex-ytr83r 321897fd55 Integrate contextual source creation with lifecycle rendering 2026-09-11 10:38:05 -07:00
Codex-ytr83r 4da92ab481 Clip behavior card labels to their bounds 2026-09-11 10:23:36 -07:00
Codex-ytr83r 7831abeed5 Record native lifecycle checks and centralize live planning 2026-09-11 10:22:50 -07:00
sorokin.oleg.edu fe8364e7eb Sync agent instructions from main 2026-09-11 19:52:27 +03:00
Codex-ytr83r 3ade397008 Record Windows desktop validation and user-paused Linux setup 2026-09-11 08:40:05 -07:00
Codex-ytr83r 29084050a7 Prepare Linux delivery and correct lifecycle rendering build types 2026-09-11 07:53:42 -07:00
Codex-ytr83r cb611a71df Bound Create labels within lifeline capsules and preserve Unicode test inputs 2026-09-11 06:56:17 -07:00
Codex-ytr83r fb77b857d2 Save reboot checkpoint with interrupted build and restart instructions 2026-09-11 06:38:54 -07:00
Codex-ytr83r a9a5768144 Record bounded Linux trial and contextual source integration review 2026-09-11 06:35:30 -07:00
Codex-ytr83r d041212d17 Merge owner instruction sync while preserving exact supplied goal 2026-09-11 06:33:51 -07:00
Codex-ytr83r b96286ea37 Record current core and lifecycle evidence and refresh project entrypoints 2026-09-11 06:30:59 -07:00
sorokin.oleg.edu dd42d0ab01 Sync agent instructions from main 2026-09-11 15:55:52 +03:00
sorokin.oleg.edu 023e3a8f09 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:45:32 -07:00
Codex-ytr83r 52f6e176cd Render explicit scenario lifetimes and preserve marker selection 2026-09-11 05:25:04 -07:00
Codex-ytr83r b853a1eca3 Import corrected creation oracle and verified external evidence 2026-09-11 05:01:57 -07:00
sorokin.oleg.edu e207b04114 Add named behavior creation and verified textual authoring examples 2026-09-11 04:37:07 -07:00
Codex-ytr83r c81e285573 Integrate published behavior work and preserve current release goal 2026-09-11 04:27:09 -07:00
sorokin.oleg.edu ff615d0ee0 Update the project goal
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-10 21:32:22 +03:00
1980 changed files with 215344 additions and 27200 deletions
+47 -10
View File
@@ -22,28 +22,65 @@ 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/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/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/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
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
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
# Preserve the independently reviewed codec handoff bytes.
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,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
Generated
+1 -64
View File
@@ -65,15 +65,6 @@ 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"
@@ -2116,25 +2107,6 @@ 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"
@@ -2181,13 +2153,9 @@ 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",
@@ -2208,6 +2176,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
@@ -2518,15 +2487,6 @@ 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"
@@ -2750,16 +2710,6 @@ 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"
@@ -3480,19 +3430,6 @@ 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"
+1 -5
View File
@@ -7,15 +7,11 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json = { workspace = true, features = ["unbounded_depth"] }
stacker = "0.1.25"
serde_json.workspace = true
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"
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
-58
View File
@@ -1,58 +0,0 @@
//! 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
@@ -1,377 +0,0 @@
//! 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(())
}
}
@@ -1,261 +0,0 @@
//! 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
@@ -1,246 +0,0 @@
//! 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
@@ -1,146 +0,0 @@
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
@@ -1,458 +0,0 @@
//! 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
@@ -1,213 +0,0 @@
//! 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
@@ -1,103 +0,0 @@
//! 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;
@@ -1,107 +0,0 @@
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
}
}
@@ -1,469 +0,0 @@
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)
}
@@ -1,32 +0,0 @@
[
{
"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"
}
]
@@ -1,240 +0,0 @@
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)
}
@@ -1,385 +0,0 @@
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()))
}
}
@@ -1,804 +0,0 @@
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());
}
+1
View File
@@ -81,6 +81,7 @@ fn diagram(
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+248 -47
View File
@@ -1,12 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
mod raw_tokens;
// 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::*;
@@ -66,8 +60,7 @@ impl ParseLimits {
let graph = project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(project.definitions.len());
.saturating_add(project.relations.len());
let records = graph
.saturating_add(1)
.saturating_add(project.diagrams.len())
@@ -86,8 +79,7 @@ impl ParseLimits {
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
.saturating_add(project.traces.len())
.saturating_add(crate::analysis::nested_record_count(project));
.saturating_add(project.traces.len());
let steps = project
.elements
.values()
@@ -150,10 +142,164 @@ 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> {
raw_tokens::legacy(source)
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
{
cursor += 1;
}
TokenClass::Whitespace
} else if source[cursor..].starts_with("//") {
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
TokenClass::Comment
} else if ch == '"' {
cursor += 1;
let mut escaped = false;
let mut closed = false;
while cursor < source.len() {
let next = source[cursor..].chars().next().expect("in bounds");
cursor += next.len_utf8();
if escaped {
escaped = false;
} else if next == '\\' {
escaped = true;
} else if next == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
TokenClass::String
} else {
TokenClass::Error
}
} else if ch.is_ascii_alphabetic() || ch == '_' {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
{
cursor += 1;
}
if reserved(&source[start..cursor])
|| matches!(&source[start..cursor], "true" | "false" | "none")
{
TokenClass::Keyword
} else {
TokenClass::Identifier
}
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
cursor += 1;
while source.as_bytes().get(cursor).is_some_and(|next| {
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
}) {
cursor += 1;
}
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
TokenClass::Number
} else {
TokenClass::Error
}
} else if "{}();,[]:=@".contains(ch) {
cursor += ch.len_utf8();
TokenClass::Punctuation
} else {
cursor += ch.len_utf8();
TokenClass::Error
};
tokens.push(TokenSpan {
class,
span: Span { start, end: cursor },
});
}
Ok(tokens)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -1159,11 +1305,7 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
if !has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1355,7 +1497,6 @@ impl Parser<'_> {
alias,
name,
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1576,9 +1717,6 @@ 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(
@@ -1685,7 +1823,7 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
@@ -1757,7 +1895,10 @@ pub(crate) fn resolve_declarations(
target.nodes.insert(node.id, node);
}
if legacy {
let migrated = source3_bridge::from_scratch(project, legacy_elements)
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
@@ -1805,15 +1946,13 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if matches!(project.schema_version, 4..=7) {
if matches!(project.schema_version, 4..=6) {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else if project.schema_version == 5 {
crate::persistence::migrate_v5(project, limits)
} else if project.schema_version == 6 {
crate::persistence::migrate_v6(project, limits)
} else {
crate::persistence::migrate_v7(project, limits)
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
(
@@ -1894,8 +2033,49 @@ 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))
@@ -1904,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
// 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];
@@ -1939,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
}
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")
@@ -1947,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
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();
id = Id::parse_str(&text).ok();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
}
j += 2;
}
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
Ok((declarations, has_unmatched_braces))
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2374,11 +2579,7 @@ 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 value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
if !super::has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1,113 +0,0 @@
//! 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 lex;
mod lower;
mod syntax;
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;
@@ -1,154 +0,0 @@
//! 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(())
}
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(())
}
}
@@ -1,171 +0,0 @@
//! 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(),
},
))
}
}
@@ -1,635 +0,0 @@
//! 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;
@@ -1,613 +0,0 @@
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);
}
}
}
@@ -1,52 +0,0 @@
//! 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)
}
@@ -1,939 +0,0 @@
//! 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)]
pub(super) enum Target {
Global(Id),
Parameter {
calculation: Id,
parameter: a::ParameterId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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)]
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)]
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;
@@ -1,7 +0,0 @@
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" {}
}
}
@@ -1,7 +0,0 @@
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" {}
}
}
@@ -1,43 +0,0 @@
// 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;
}
@@ -1,43 +0,0 @@
// 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;
}
@@ -1,888 +0,0 @@
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));
}
@@ -1,263 +0,0 @@
//! 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(),
}
}
@@ -1,432 +0,0 @@
//! 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 {
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 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,
})
}
@@ -1,503 +0,0 @@
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 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)
);
}
}
@@ -1,664 +0,0 @@
//! 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;
@@ -1,284 +0,0 @@
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(())
}
}
@@ -1,910 +0,0 @@
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
@@ -1,347 +0,0 @@
//! 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());
}
}
}
@@ -1,52 +0,0 @@
//! 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;
@@ -1,188 +0,0 @@
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")
);
}
@@ -0,0 +1,386 @@
//! 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);
}
+4 -2
View File
@@ -1,6 +1,4 @@
//! 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;
@@ -10,6 +8,7 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
pub mod source_files;
@@ -20,6 +19,9 @@ pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use scenario_layout::{
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
};
pub use semantic::*;
pub use session::*;
mod demo;
-1
View File
@@ -75,7 +75,6 @@ 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
@@ -1,62 +0,0 @@
//! 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(())
}
+30 -43
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 8;
pub const SCHEMA_VERSION: u32 = 7;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -64,12 +64,9 @@ pub enum Kind {
Terminate,
Event,
Constraint,
EngineeringProperty,
AnalysisCase,
AnalysisCheck,
}
impl Kind {
pub const ALL: [Self; 42] = [
pub const ALL: [Self; 39] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -109,9 +106,6 @@ impl Kind {
Self::Terminate,
Self::Event,
Self::Constraint,
Self::EngineeringProperty,
Self::AnalysisCase,
Self::AnalysisCheck,
];
pub fn label(self) -> &'static str {
match self {
@@ -154,9 +148,6 @@ 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 {
@@ -165,9 +156,6 @@ 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)
}
@@ -664,23 +652,6 @@ 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 {
@@ -781,8 +752,7 @@ impl ElementBody {
}
}
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
// Analysis creation requires explicitly authored types and bindings.
if !kind.supports(layer) || kind.is_analysis() {
if !kind.supports(layer) {
return None;
}
Some(match kind {
@@ -894,9 +864,6 @@ 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,
}
}
}
@@ -1469,6 +1436,7 @@ pub enum ProjectionKind {
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
Scenario { scenario: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
@@ -1478,8 +1446,30 @@ impl ProjectionKind {
}
}
/// The semantic owner of a saved Scenario representation.
pub fn scenario(self) -> Option<Id> {
match self {
Self::Scenario { scenario } => Some(scenario),
_ => None,
}
}
/// Explicitly classify architecture views; absence of a state machine is insufficient.
pub fn is_architecture(self) -> bool {
match self {
Self::Overview
| Self::Decomposition
| Self::Allocation
| Self::Traceability
| Self::FunctionalDataflow
| Self::Capabilities => true,
Self::StateMachine { .. } | Self::Scenario { .. } => false,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::Scenario { .. } => false,
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
@@ -1504,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Scenario { .. } => "Scenario",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1528,6 +1519,8 @@ pub struct Diagram {
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
pub routes: Vec<EdgeRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<crate::scenario_layout::DiagramLayout>,
}
/// Authored presentation for one existing relation or trace in one diagram.
/// Computed endpoints and automatic routing elbows are never persisted here.
@@ -1658,8 +1651,6 @@ 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>,
@@ -1752,7 +1743,6 @@ impl Project {
alias: "mission".into(),
name: "Untitled mission".into(),
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1762,7 +1752,6 @@ 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())));
@@ -1820,9 +1809,6 @@ 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;
}
@@ -1961,6 +1947,7 @@ impl Project {
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
},
);
id
+97 -209
View File
@@ -2,10 +2,6 @@ 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;
@@ -13,8 +9,6 @@ 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)]
@@ -136,19 +130,6 @@ 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!(
@@ -157,43 +138,65 @@ fn decode_with_report_profile(
)));
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
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?;
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)?;
}
match version {
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)
{
Some(1) => {
let old = json_codec::legacy(bytes)?;
let old = serde_json::from_slice(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_with_report_limited(old, limits)?
}
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)?,
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)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
.into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
));
}
}
@@ -222,38 +225,6 @@ fn decode_with_report_profile(
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 {
@@ -377,7 +348,6 @@ 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
@@ -398,49 +368,48 @@ 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> {
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());
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
serde_json::Value::Array(values) => {
pending.extend(values.iter().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) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
@@ -451,7 +420,6 @@ 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;
@@ -461,30 +429,6 @@ 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,
@@ -902,9 +846,6 @@ 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)
@@ -920,10 +861,7 @@ 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(&json_codec::read_bounded(
path,
json_codec::MAX_DOCUMENT_BYTES,
)?)
decode_with_report(&std::fs::read(path)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
@@ -936,69 +874,19 @@ 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 is_source = path
let mut bytes = if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
let mut bytes = if is_source {
source(p)
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
crate::language::format(p).into_bytes()
} else {
json_codec::encode(p, profile, depth_128)?
serde_json::to_vec_pretty(p)?
};
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())
@@ -1007,8 +895,8 @@ fn save_checked_profile_with_source(
temp.write_all(&bytes)?;
temp.as_file().sync_all()?;
if let Some(expected) = expected {
match json_codec::matches_file(path, expected) {
Ok(true) => {}
match std::fs::read(path) {
Ok(current) if current == expected => {}
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)),
@@ -1,427 +0,0 @@
//! 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;
@@ -1,612 +0,0 @@
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"
);
}
}
@@ -1,145 +0,0 @@
//! 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}"
);
});
}
+6
View File
@@ -278,6 +278,11 @@ fn project_diagram_impl(
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
// Saved Scenario layout is not an architecture/state projection. Its dedicated
// renderer is not wired yet; never interpret its payload through this fallback.
if d.projection.scenario().is_some() {
return ProjectedView::default();
}
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -394,6 +399,7 @@ fn project_diagram_impl(
continue;
}
let included = match d.projection {
ProjectionKind::Scenario { .. } => false,
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
+83 -37
View File
@@ -1,14 +1,8 @@
use crate::*;
use std::collections::BTreeSet;
/// Architectural/project references, including analysis subjects/properties.
/// Layer-neutral definition references and scoped parameters have separate domains.
/// Every live reference in a typed element, including structural ownership.
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));
@@ -20,8 +14,6 @@ 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);
@@ -211,10 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
/// Every semantic/presentation reference in a diagram, excluding its own identities.
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
let mut copy = d.clone();
let mut refs = Vec::new();
visit_diagram_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
d.view_options.visit_references(&mut visit);
match &mut d.projection {
ProjectionKind::StateMachine { machine } => visit(machine),
ProjectionKind::Scenario { scenario } => visit(scenario),
_ => {}
}
if let Some(layout) = &mut d.layout {
layout.visit_references(&mut visit);
}
for route in &mut d.routes {
visit(&mut route.edge);
}
for node in d.nodes.values_mut() {
for anchor in &mut node.port_anchors {
visit(&mut anchor.port);
}
visit(&mut node.element);
}
}
/// Preflight reference-key collisions before the caller changes object identities.
/// Fresh targets are allowed. This is not whole-project admission: unrelated
/// invalid records remain repairable within a transaction. Future typed reference
/// domains must compose their own preflight here before any remap mutation.
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
if old == new {
return Ok(());
}
let remap = |id: Id| if id == old { new } else { id };
for diagram in p.diagrams.values() {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
continue;
};
let mut lanes = std::collections::BTreeMap::new();
for lane in &layout.lanes {
let affected = lane.lifeline == old;
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario lane position.".into(),
));
}
}
let mut messages = std::collections::BTreeMap::new();
for message in &layout.messages {
let mut address = message.address.clone();
let affected = address.occurrence == old || address.references.contains(&old);
address.occurrence = remap(address.occurrence);
for reference in &mut address.references {
*reference = remap(*reference);
}
if let Some(previous_affected) = messages.insert(address, affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario message position.".into(),
));
}
}
}
Ok(())
}
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);
check_remap(p, old, new)?;
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -230,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
visit_diagram_refs(d, |id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
@@ -351,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
.or_else(|| d.projection.scenario())
.is_some_and(|owner| removed.contains(&owner))
{
removed.insert(d.id);
}
@@ -417,9 +461,6 @@ 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));
@@ -427,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
crate::scenario_layout::prune_deleted(d, &removed);
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -604,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
}
#[cfg(test)]
#[path = "references/scenario_layout_tests.rs"]
mod scenario_layout_tests;
@@ -0,0 +1,388 @@
use super::*;
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
panic!("expected authored Scenario positions")
};
layout
}
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&view).unwrap().layout
else {
panic!("expected authored Scenario positions")
};
layout
}
fn admitted(project: &Project) {
let errors: Vec<_> = crate::validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == crate::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let lanes = ["Sender", "Receiver"].map(|name| {
project
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
.unwrap()
});
let messages = ["First message", "Second message"].map(|name| {
let id = project
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.to_vec();
let views = ["delivery_view", "review_view"].map(|alias| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 120.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 420.,
},
],
messages: vec![
ScenarioMessagePosition {
address: address(messages[0]),
send_y: 220.,
},
ScenarioMessagePosition {
address: address(messages[1]),
send_y: 360.,
},
],
})),
},
);
id
});
admitted(&project);
Fixture {
project,
owner,
lanes,
messages,
views,
}
}
#[test]
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
let mut f = fixture();
let before = f.project.clone();
let temporary = Id::new_v4();
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
admitted(&f.project);
assert_eq!(
f.project.elements[&f.owner].steps().unwrap(),
&vec![f.messages[1], f.messages[0]]
);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(
rows.lanes,
vec![
ScenarioLanePosition {
lifeline: f.lanes[1],
x: 120.
},
ScenarioLanePosition {
lifeline: f.lanes[0],
x: 420.
}
]
);
assert_eq!(
rows.messages,
vec![
ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 220.
},
ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 360.
}
]
);
}
// Reverse the swaps: no values or identities other than those requested drift.
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
assert_eq!(f.project, before);
}
#[test]
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
for message in [false, true] {
let mut f = fixture();
let [old, new] = if message { f.messages } else { f.lanes };
let before = f.project.clone();
assert!(check_remap(&f.project, old, new).is_err());
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
}
}
#[test]
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
let mut f = fixture();
let fresh = Id::new_v4();
// A repair candidate can already have a dangling coordinate key. It must not
// silently merge with the live lane, even though the destination ID is vacant.
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
let before = f.project.clone();
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
assert_eq!(f.project, before);
}
#[test]
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
let mut f = fixture();
let [old, new] = [Id::new_v4(), Id::new_v4()];
let rows = layout_mut(&mut f.project, f.views[0]);
// Future/invalid expanded candidates still participate in reference safety.
rows.messages[0].address.references = vec![old];
rows.messages[1].address = OccurrenceEndpoint {
references: vec![new],
occurrence: f.messages[0],
};
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
let before = f.project.clone();
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
let fresh = Id::new_v4();
remap_references(&mut f.project, old, fresh).unwrap();
let rows = layout(&f.project, f.views[0]);
assert_eq!(rows.messages[0].address.references, vec![fresh]);
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
assert_eq!(rows.messages[1].address.references, vec![new]);
}
#[test]
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
let mut f = fixture();
let other_owner = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
.unwrap();
let mut other_view = f.project.diagrams[&f.views[0]].clone();
other_view.id = Id::new_v4();
other_view.alias = "independent_view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
other_view.layout = None;
f.project.diagrams.insert(other_view.id, other_view.clone());
admitted(&f.project);
let fresh_owner = Id::new_v4();
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
.unwrap()
.project;
for view in f.views {
assert_eq!(
f.project.diagrams[&view].projection.scenario(),
Some(fresh_owner)
);
}
let impact = deletion_impact(&f.project, fresh_owner);
assert!(
f.views
.iter()
.chain(&f.lanes)
.chain(&f.messages)
.all(|id| impact.contains(id))
);
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
delete_cascade(&mut f.project, fresh_owner);
assert_eq!(f.project.diagrams[&other_view.id], other_view);
assert!(
f.views
.iter()
.all(|id| !f.project.diagrams.contains_key(id))
);
admitted(&f.project);
}
#[test]
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
let mut f = fixture();
let lane = f.project.elements[&f.lanes[0]].clone();
let message = f.project.elements[&f.messages[0]].clone();
delete_cascade(&mut f.project, lane.id);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
// Missing endpoint assignment does not erase a still-live message's spacing.
assert_eq!(rows.messages.len(), 2);
}
let mut pending = BTreeSet::new();
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
// Pruning happens during deletion, before later batch commands can recreate IDs.
assert!(
layout(&f.project, f.views[0])
.messages
.iter()
.all(|row| row.address.occurrence != message.id)
);
f.project.elements.insert(lane.id, lane);
f.project.elements.insert(message.id, message);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, f.messages[0]);
finish_deletions(&mut f.project, &pending);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(
rows.messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
}
admitted(&f.project);
}
#[test]
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
let mut f = fixture();
let unrelated = Id::new_v4();
let rows = layout_mut(&mut f.project, f.views[1]);
let invalid = ScenarioLanePosition {
lifeline: unrelated,
x: MAX_COORDINATE * 2.,
};
rows.lanes.extend([invalid.clone(), invalid]);
let fresh = Id::new_v4();
let untouched = f.project.diagrams[&f.views[1]].clone();
let before = f.project.clone();
remap_references(&mut f.project, f.owner, f.owner).unwrap();
assert_eq!(f.project, before);
// The duplicate lane records do not involve the remapped message identity.
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
assert_eq!(
layout(&f.project, f.views[1]).lanes,
layout_from(&untouched).lanes
);
assert_eq!(
layout(&f.project, f.views[1]).messages[0]
.address
.occurrence,
fresh
);
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
}
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("missing layout")
};
layout
}
#[test]
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
let mut f = fixture();
// Expanded addresses are not admitted in this slice, but deleting their
// explicit references must still make an intermediate repair candidate safe.
layout_mut(&mut f.project, f.views[0]).messages[0]
.address
.references = vec![f.lanes[0]];
delete_cascade(&mut f.project, f.lanes[0]);
assert_eq!(
layout(&f.project, f.views[0]).messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
assert!(f.project.elements.contains_key(&f.messages[0]));
admitted(&f.project);
}
+429
View File
@@ -0,0 +1,429 @@
//! Authored Scenario presentation. Structural admission never changes event order.
use crate::{
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
Project, SharedBody, validation,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DiagramLayout {
Scenario(ScenarioLayout),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLayout {
#[serde(default)]
pub lanes: Vec<ScenarioLanePosition>,
#[serde(default)]
pub messages: Vec<ScenarioMessagePosition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLanePosition {
pub lifeline: Id,
pub x: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioMessagePosition {
pub address: OccurrenceEndpoint,
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
pub send_y: f32,
}
impl DiagramLayout {
/// Visit every explicit reference, including unsupported paths in a candidate.
/// The caller owns remap preflight and final atomic validation.
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
match self {
Self::Scenario(layout) => {
for lane in &mut layout.lanes {
visit(&mut lane.lifeline);
}
for message in &mut layout.messages {
for reference in &mut message.address.references {
visit(reference);
}
visit(&mut message.address.occurrence);
}
}
}
}
}
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
/// One payload plus one record per lane/message. No graph objects are introduced.
pub fn record_count(diagram: &Diagram) -> usize {
match &diagram.layout {
None => 0,
Some(DiagramLayout::Scenario(layout)) => 1usize
.saturating_add(layout.lanes.len())
.saturating_add(layout.messages.len()),
}
}
fn coordinate(value: f32) -> bool {
value.is_finite() && value.abs() <= MAX_COORDINATE
}
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
let scenario = diagram
.projection
.scenario()
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
if !p
.elements
.get(&scenario)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
{
return Err(ModelError(
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
));
}
Ok(scenario)
}
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
p.elements.get(&lifeline).is_some_and(|element| {
element.kind() == Kind::Lifeline
&& element.parent == Some(owner)
&& element.layer() == layer
})
}
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
/// cycles terminate, and malformed parent/step links do not manufacture membership.
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
let mut messages = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut pending = vec![owner];
while let Some(scope) = pending.pop() {
if !visited.insert(scope) {
continue;
}
let Some(element) = p.elements.get(&scope) else {
continue;
};
for id in element.steps().into_iter().flatten() {
let Some(item) = p.elements.get(id) else {
continue;
};
if item.parent != Some(scope) || item.layer() != layer {
continue;
}
match item.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) => {
messages.insert(*id);
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
for branch in branches {
if p.elements.get(branch).is_some_and(|operand| {
operand.parent == Some(*id)
&& operand.layer() == layer
&& matches!(
operand.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}) {
pending.push(*branch);
}
}
}
_ => {}
}
}
}
messages
}
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
if diagram.projection.scenario().is_none() {
if diagram.layout.is_some() {
validation::error(
out,
diagram.id,
"scenario_layout_projection",
"layout",
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
);
}
return;
}
let scenario = match owner(p, diagram) {
Ok(owner) => owner,
Err(error) => {
validation::error(
out,
diagram.id,
"scenario_diagram_context",
"projection",
error.0,
);
return;
}
};
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
validation::error(
out,
diagram.id,
"scenario_diagram_content",
"layout",
"Scenario views do not accept architecture node placements or edge routes.",
);
}
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
return;
};
let mut lanes = BTreeSet::new();
for lane in &layout.lanes {
if !lanes.insert(lane.lifeline) {
validation::error(
out,
diagram.id,
"duplicate_scenario_lane",
"layout",
"A Scenario layout has at most one position per lifeline.",
);
}
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
validation::error(
out,
diagram.id,
"scenario_layout_lifeline",
"layout",
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
);
}
if !coordinate(lane.x) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
let members = direct_messages(p, scenario, diagram.layer);
let mut addresses = BTreeSet::new();
for message in &layout.messages {
// Reject expanded paths without cloning or indexing their potentially large contents.
if !message.address.references.is_empty() {
validation::error(
out,
diagram.id,
"scenario_layout_address",
"layout",
"Expanded-reference message positions are not supported; references must be empty.",
);
} else if !addresses.insert(message.address.occurrence) {
validation::error(
out,
diagram.id,
"duplicate_scenario_message",
"layout",
"A Scenario layout has at most one position per message address.",
);
}
if !members.contains(&message.address.occurrence) {
validation::error(
out,
diagram.id,
"scenario_layout_message",
"layout",
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
);
}
if !coordinate(message.send_y) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
}
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
let diagram = p
.diagrams
.get(&id)
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
let scenario = owner(p, diagram)?;
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
return Err(ModelError(
"Scenario views do not accept architecture node placements or edge routes.".into(),
));
}
Ok((diagram, scenario))
}
fn check_message(
p: &Project,
diagram: &Diagram,
owner: Id,
address: &OccurrenceEndpoint,
) -> Result<(), ModelError> {
if !address.references.is_empty() {
return Err(ModelError(
"Expanded-reference message positions are not supported; references must be empty."
.into(),
));
}
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
}
Ok(())
}
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
let DiagramLayout::Scenario(layout) = diagram
.layout
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
layout
}
/// Target-scoped preparation, not whole-project or whole-layout validation.
/// Unrelated intermediate errors remain repairable by later commands in a batch.
pub fn set_lane(
p: &Project,
diagram: Id,
position: ScenarioLanePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
return Err(ModelError(
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.lanes
.iter()
.filter(|row| row.lifeline == position.lifeline)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate lane position.".into(),
));
}
if let Some(existing) = layout
.lanes
.iter_mut()
.find(|row| row.lifeline == position.lifeline)
{
if existing.x != position.x {
*existing = position;
}
} else {
layout.lanes.push(position);
}
Ok(result)
}
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, lifeline) {
return Err(ModelError(
"Removing a lane position requires an owned Lifeline.".into(),
));
}
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.lanes.retain(|row| row.lifeline != lifeline);
}
Ok(result)
}
pub fn set_message(
p: &Project,
diagram: Id,
position: ScenarioMessagePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, &position.address)?;
if !coordinate(position.send_y) {
return Err(ModelError(
"Message position requires a finite bounded send_y coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.messages
.iter()
.filter(|row| row.address == position.address)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate message position.".into(),
));
}
if let Some(existing) = layout
.messages
.iter_mut()
.find(|row| row.address == position.address)
{
if existing.send_y != position.send_y {
*existing = position;
}
} else {
layout.messages.push(position);
}
Ok(result)
}
pub fn remove_message(
p: &Project,
diagram: Id,
address: &OccurrenceEndpoint,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, address)?;
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.messages.retain(|row| &row.address != address);
}
Ok(result)
}
/// Prune only identities in the caller's actual deletion closure, never visibility
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
layout
.lanes
.retain(|lane| !deleted.contains(&lane.lifeline));
layout.messages.retain(|message| {
!deleted.contains(&message.address.occurrence)
&& !message
.address
.references
.iter()
.any(|id| deleted.contains(id))
});
}
}
#[cfg(test)]
mod tests;
+800
View File
@@ -0,0 +1,800 @@
use super::*;
use crate::{
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
ScenarioViewpoint,
};
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
performer: Id,
lane: Id,
repeated_lane: Id,
first: Id,
second: Id,
nested: Id,
fragment: Id,
branch: Id,
view: Id,
other_view: Id,
exchange: Id,
}
fn set_body(element: &mut Element, body: OccurrenceBody) {
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn diagram(owner: Id, alias: &str) -> Diagram {
Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
}
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn has_code(project: &Project, code: &str) -> bool {
validation::validate(project)
.iter()
.any(|issue| issue.code == code)
}
fn layout(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("expected saved payload")
};
layout
}
fn save(project: &mut Project, replacement: Diagram) {
project.diagrams.insert(replacement.id, replacement);
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
else {
panic!("Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
.unwrap();
let receiver = project
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
.unwrap();
let lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
.unwrap();
let repeated_lane = project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Controller review lane",
Some(owner),
)
.unwrap();
let receiver_lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
.unwrap();
for (id, represented) in [
(lane, performer),
(repeated_lane, performer),
(receiver_lane, receiver),
] {
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = Id::new_v4();
project.relations.insert(
exchange,
Relation {
id: exchange,
alias: "delivery_channel".into(),
name: "Delivery".into(),
description: String::new(),
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
},
);
let first = project
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
.unwrap();
let second = project
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
.unwrap();
for (id, sender) in [(first, lane), (second, repeated_lane)] {
set_body(
project.elements.get_mut(&id).unwrap(),
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver_lane),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
}
let fragment = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Optional delivery",
Some(owner),
)
.unwrap();
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = project
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
.unwrap();
let nested = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Unfinished nested delivery",
Some(branch),
)
.unwrap();
set_body(
project.elements.get_mut(&nested).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
set_body(
project.elements.get_mut(&branch).unwrap(),
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![nested],
},
);
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![branch],
},
);
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![first, second, fragment];
let view_record = diagram(owner, "nominal_view");
let other_record = diagram(owner, "review_view");
let view = view_record.id;
let other_view = other_record.id;
save(&mut project, view_record);
save(&mut project, other_record);
assert_admitted(&project);
Fixture {
project,
owner,
performer,
lane,
repeated_lane,
first,
second,
nested,
fragment,
branch,
view,
other_view,
exchange,
}
}
#[test]
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
let mut f = fixture();
let semantics = (f.project.elements.clone(), f.project.relations.clone());
for (view, line, x) in [
(f.view, f.lane, 420.),
(f.view, f.repeated_lane, 680.),
(f.other_view, f.lane, 900.),
] {
let result =
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
save(&mut f.project, result);
}
for (view, id, y) in [
(f.view, f.first, 200.),
(f.view, f.second, 350.),
(f.other_view, f.first, 260.),
] {
let result = set_message(
&f.project,
view,
ScenarioMessagePosition {
address: address(id),
send_y: y,
},
)
.unwrap();
save(&mut f.project, result);
}
assert_eq!(
layout(&f.project.diagrams[&f.view]).lanes,
vec![
ScenarioLanePosition {
lifeline: f.lane,
x: 420.
},
ScenarioLanePosition {
lifeline: f.repeated_lane,
x: 680.
},
]
);
assert_eq!(
layout(&f.project.diagrams[&f.view])
.messages
.iter()
.map(|r| (r.address.occurrence, r.send_y))
.collect::<Vec<_>>(),
vec![(f.first, 200.), (f.second, 350.)]
);
assert_eq!(
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
260.
);
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
assert_eq!(
(f.project.elements.clone(), f.project.relations.clone()),
semantics
);
assert_admitted(&f.project);
}
#[test]
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.nested),
send_y: -80.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
let before = f.project.clone();
let expanded = OccurrenceEndpoint {
references: vec![f.fragment],
occurrence: f.nested,
};
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: expanded.clone(),
send_y: 400.
}
)
.is_err()
);
assert!(remove_message(&f.project, f.view, &expanded).is_err());
assert_eq!(f.project, before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
assert!(has_code(&f.project, "scenario_layout_address"));
}
#[test]
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
let mut f = fixture();
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.performer,
x: 10.
}
)
.is_err()
);
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(id),
send_y: 100.
}
)
.is_err()
);
}
let foreign = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
.unwrap();
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.first);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
}
#[test]
fn duplicate_positions_reject_even_when_values_match() {
let mut f = fixture();
let lane = ScenarioLanePosition {
lifeline: f.lane,
x: 10.,
};
let message = ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
};
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
entries.lanes = vec![lane.clone(), lane.clone()];
entries.messages = vec![message.clone(), message.clone()];
assert!(has_code(&f.project, "duplicate_scenario_lane"));
assert!(has_code(&f.project, "duplicate_scenario_message"));
assert!(set_lane(&f.project, f.view, lane).is_err());
assert!(set_message(&f.project, f.view, message).is_err());
// Explicit removal can repair all duplicates of the targeted identity.
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
let mut f = fixture();
let absent = f.project.diagrams[&f.view].clone();
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
assert_eq!(
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
absent
);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("layout")
.is_none()
);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 0.,
},
)
.unwrap();
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 0.,
},
)
.unwrap();
assert_eq!(
layout(&result).messages[0].send_y.to_bits(),
(-0.0_f32).to_bits()
);
assert_eq!(
serde_json::to_string(&result).unwrap(),
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
);
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
assert_eq!(
result.layout,
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
);
assert_ne!(result, absent);
}
#[test]
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
let mut f = fixture();
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
let before = serde_json::to_string(&f.project).unwrap();
for value in [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
MAX_COORDINATE + 1.,
] {
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value
}
)
.is_err()
);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value
}
)
.is_err()
);
}
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
assert!(has_code(&f.project, "scenario_layout_coordinate"));
}
#[test]
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
let mut f = fixture();
payload(f.project.diagrams.get_mut(&f.view).unwrap())
.messages
.push(ScenarioMessagePosition {
address: address(f.second),
send_y: f32::INFINITY,
});
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
assert!(has_code(&f.project, "scenario_layout_coordinate"));
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.second),
send_y: 300.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
let mut f = fixture();
let scenario = f.project.diagrams[&f.view].projection;
assert_eq!(scenario.scenario(), Some(f.owner));
assert!(!scenario.is_architecture());
assert!(!scenario.includes(Kind::Lifeline));
assert!(ProjectionKind::Overview.is_architecture());
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
assert!(has_code(&f.project, "scenario_diagram_context"));
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
f.project
.diagrams
.get_mut(&f.view)
.unwrap()
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
waypoints: vec![],
label: None,
});
assert!(has_code(&f.project, "scenario_diagram_content"));
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
payload(f.project.diagrams.get_mut(&f.view).unwrap());
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
assert!(has_code(&f.project, "scenario_layout_projection"));
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 10.
}
)
.is_err()
);
}
#[test]
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
assert_eq!(
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
f.project.diagrams[&f.view]
);
assert_eq!(
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
serde_json::json!(f.first)
);
let mut unknown = encoded.clone();
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
let mut missing = encoded;
missing["layout"]["Scenario"]["messages"][0]["address"]
.as_object_mut()
.unwrap()
.remove("references");
assert!(serde_json::from_value::<Diagram>(missing).is_err());
}
#[test]
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
let mut f = fixture();
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let mut copied = f.project.diagrams[&f.view].clone();
copied.id = f.other_view;
copied.alias = "review_view".into();
copied.name = "Review view".into();
save(&mut f.project, copied.clone());
assert_admitted(&f.project);
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
let mut candidate = f.project.diagrams[&f.view].clone();
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
assert!(layout(&candidate).lanes.is_empty());
assert_eq!(layout(&candidate).messages.len(), 1);
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
assert!(layout(&candidate).messages.is_empty());
assert!(candidate.layout.is_some());
assert_eq!(f.project.diagrams[&f.other_view], copied);
}
#[test]
fn reference_visitor_and_record_count_cover_explicit_rows() {
let f = fixture();
let mut d = f.project.diagrams[&f.view].clone();
assert_eq!(record_count(&d), 0);
let rows = payload(&mut d);
rows.lanes.push(ScenarioLanePosition {
lifeline: f.lane,
x: 50.,
});
rows.messages.push(ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
});
assert_eq!(record_count(&d), 3);
let replacement = Id::new_v4();
d.layout.as_mut().unwrap().visit_references(|id| {
if *id == f.first {
*id = replacement;
}
});
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
assert_eq!(layout(&d).messages[0].send_y, 100.);
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
}
#[test]
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
let mut f = fixture();
let called = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
f.project.elements.get_mut(&called).unwrap().shared_mut()
else {
panic!("Called Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let called_message = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
.unwrap();
set_body(
f.project.elements.get_mut(&called_message).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
*f.project
.elements
.get_mut(&called)
.unwrap()
.steps_mut()
.unwrap() = vec![called_message];
let usage = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
.unwrap();
set_body(
f.project.elements.get_mut(&usage).unwrap(),
OccurrenceBody::ScenarioUse {
scenario: Some(called),
bindings: vec![],
},
);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(usage);
assert_admitted(&f.project);
for target in [
address(called_message),
address(usage),
OccurrenceEndpoint {
references: vec![usage],
occurrence: called_message,
},
] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: target,
send_y: 200.
}
)
.is_err()
);
}
}
mod source_lifecycle;
@@ -0,0 +1,245 @@
//! Source/command lifecycle checks for the candidate payload. Public version
//! assignment remains the coordinated codec integration gate.
use super::*;
use crate::{Command, Session, language, prepare_command, source_project::*};
fn authored(f: &Fixture) -> CompiledSourceProject {
let mut bundle = format_bundle(&f.project);
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &initial.index.declarations[&f.view];
let source = bundle.files.get_mut(&location.path).unwrap();
let text = format!(
r#"diagram nominal_view "Nominal review" id "{}" {{
projection {{ Scenario {{ scenario @{}; }}; }};
layout {{ Scenario {{
lanes [
// Controller spacing rationale.
{{ lifeline @ // first lane identity
{}; x 4.20e2; }},
// Repeated performer has its own lane.
{{ lifeline @{}; x 680; }}
];
messages [
// First delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
// Second delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
];
}}; }};
}}
"#,
f.view,
f.project.elements[&f.owner].alias,
f.project.elements[&f.lane].alias,
f.project.elements[&f.repeated_lane].alias,
f.project.elements[&f.first].alias,
f.project.elements[&f.second].alias
);
source.replace_range(location.span.start..location.span.end, &text);
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
}
#[test]
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
let f = fixture();
let original = authored(&f);
let mut changed = original.decoded.project.clone();
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
assert_eq!(&updated.index.declarations[&f.view].path, path);
let text = &updated.bundle.files[path];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
"{text}"
);
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
assert!(
text.contains(
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
),
"{text}"
);
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(text.contains("send_y 390"), "{text}");
for (other, bytes) in &original.bundle.files {
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
assert_eq!(&updated.bundle.files[other], bytes);
}
}
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
}
#[test]
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
let f = fixture();
let original = authored(&f);
let temporary = Id::new_v4();
let command = Command::Batch(vec![
Command::Reidentify(f.first, temporary),
Command::Reidentify(f.second, f.first),
Command::Reidentify(temporary, f.second),
]);
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
let mut changed = prepared.project;
payload(changed.diagrams.get_mut(&f.view).unwrap())
.messages
.swap(0, 1);
let updated = update_bundle_with_identity_map(
&original,
&changed,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
let text = &updated.bundle.files[path];
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
let first_comment = text.find("// First delivery").unwrap();
assert!(text[first_comment..].contains(&format!(
"occurrence @{}",
changed.elements[&f.second].alias
)));
assert!(text[first_comment..].contains("send_y 2.00e2"));
}
#[test]
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
let f = fixture();
let original = authored(&f);
let mut session = Session::new(original.decoded.project.clone());
session
.execute(Command::Batch(vec![
Command::SetScenarioLanePosition {
diagram: f.view,
position: ScenarioLanePosition {
lifeline: f.lane,
x: 510.,
},
},
Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
},
]))
.unwrap();
let changed = session.project().clone();
assert_eq!(session.revision(), 1);
assert_eq!(changed.elements, original.decoded.project.elements);
assert_eq!(changed.relations, original.decoded.project.relations);
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
session.undo();
assert_eq!(session.project(), &original.decoded.project);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
})
.unwrap();
assert_eq!(session.revision(), 3);
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
assert_eq!(noop.bundle, updated.bundle);
}
#[test]
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
let f = fixture();
let before = language::format(&f.project);
let mut session = Session::new(f.project.clone());
session
.execute(Command::RemoveScenarioLanePosition {
diagram: f.view,
lifeline: f.lane,
})
.unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(language::format(session.project()), before);
let mut present = f.project.clone();
present.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let text = language::format(&present);
assert_ne!(text, before);
assert_eq!(language::parse(&text).unwrap(), present);
assert_eq!(language::parse(&before).unwrap(), f.project);
}
#[test]
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
let f = fixture();
let original = authored(&f);
let before = original.bundle.clone();
let path = original.index.declarations[&f.view].path.clone();
for replacement in ["x 1e30", "x not_a_number"] {
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace("x 4.20e2", replacement);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace(
&format!("occurrence @{}", f.project.elements[&f.first].alias),
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
#[test]
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
let f = fixture();
let mut bundle = format_bundle(&f.project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &compiled.index.declarations[&f.view];
let path = location.path.clone();
bundle.files.get_mut(&path).unwrap().insert_str(
location.span.end - 1,
"\n layout // explicitly automatic\n none;\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut described = original.decoded.project.clone();
described.diagrams.get_mut(&f.view).unwrap().description =
"Review spacing independently".into();
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
assert_eq!(updated.decoded.project, described);
let mut empty = described.clone();
empty.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
assert_eq!(present.decoded.project, empty);
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, described);
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
-3
View File
@@ -604,9 +604,6 @@ 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| {
+42 -65
View File
@@ -19,25 +19,26 @@ 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),
EditDiagram(Id, Diagram),
SetScenarioLanePosition {
diagram: Id,
position: ScenarioLanePosition,
},
RemoveScenarioLanePosition {
diagram: Id,
lifeline: Id,
},
SetScenarioMessagePosition {
diagram: Id,
position: ScenarioMessagePosition,
},
RemoveScenarioMessagePosition {
diagram: Id,
address: OccurrenceEndpoint,
},
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
@@ -468,47 +469,6 @@ 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());
@@ -544,6 +504,26 @@ fn apply_command(
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetScenarioLanePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::SetScenarioMessagePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioMessagePosition { diagram, address } => {
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
@@ -891,10 +871,6 @@ 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(());
@@ -938,13 +914,9 @@ 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()))?;
crate::references::check_remap(p, old, new)?;
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);
@@ -975,6 +947,11 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
if d.projection.scenario().is_some() {
return Err(ModelError(
"Scenario layout requires the Scenario geometry operation.".into(),
));
}
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
+109 -7
View File
@@ -930,7 +930,18 @@ pub fn update_bundle_with_identity_map(
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
let context = if old.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
};
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
context,
local,
);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -1119,6 +1130,29 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
enum EditContext {
Other,
Diagram,
Layout,
Scenario,
Lanes,
Messages,
}
impl EditContext {
fn field(self, name: &str) -> Self {
match (self, name) {
(Self::Diagram, "layout") => Self::Layout,
(Self::Layout, "Scenario") => Self::Scenario,
(Self::Scenario, "lanes") => Self::Lanes,
(Self::Scenario, "messages") => Self::Messages,
_ => Self::Other,
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
@@ -1185,6 +1219,7 @@ impl SyntaxUpdate<'_> {
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
context: EditContext,
edits: &mut LocalEdits,
) {
use language::EditKind;
@@ -1200,7 +1235,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.record(actual.span, fields, previous, next, edits);
self.record(actual.span, fields, previous, next, context, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
@@ -1210,7 +1245,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.list(actual.span, items, previous, next, edits);
self.list(actual.span, items, previous, next, context, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
@@ -1263,6 +1298,7 @@ impl SyntaxUpdate<'_> {
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
context: EditContext,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
@@ -1286,7 +1322,13 @@ impl SyntaxUpdate<'_> {
},
new_key.clone(),
));
self.value(&field.value, Some(&old.value), &new.value, edits);
self.value(
&field.value,
Some(&old.value),
&new.value,
context.field(new_key),
edits,
);
return;
}
}
@@ -1298,9 +1340,16 @@ impl SyntaxUpdate<'_> {
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
context.field(key),
edits,
);
} else {
} else if !(matches!(context, EditContext::Diagram)
&& key == "layout"
&& before.is_some_and(|fields| !fields.contains_key(key)))
{
// Canonical None omits layout. Preserve an authored explicit
// none while another field changes; a real Some->None clear
// still removes the former payload and retains its trivia.
erase_syntax(self.source, field.span, edits);
}
}
@@ -1355,6 +1404,7 @@ impl SyntaxUpdate<'_> {
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
context: EditContext,
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
@@ -1367,6 +1417,7 @@ impl SyntaxUpdate<'_> {
self.old_aliases,
self.legacy_value_paths,
None,
context,
)
})
.collect();
@@ -1379,6 +1430,7 @@ impl SyntaxUpdate<'_> {
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
context,
)
})
.collect();
@@ -1407,7 +1459,7 @@ impl SyntaxUpdate<'_> {
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, edits);
self.value(current, previous.get(i), next, EditContext::Other, edits);
}
return;
}
@@ -1440,7 +1492,13 @@ impl SyntaxUpdate<'_> {
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(current, previous.get(index), next, &mut local);
self.value(
current,
previous.get(index),
next,
EditContext::Other,
&mut local,
);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
@@ -1483,14 +1541,58 @@ impl SyntaxUpdate<'_> {
}
}
fn scenario_item_key(
value: &language::EditValue,
aliases: &BTreeMap<String, String>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
let EditKind::Record(fields) = &value.kind else {
return None;
};
let reference = |value: &language::EditValue| {
if let EditKind::Reference(alias) = &value.kind {
aliases.get(alias).cloned()
} else {
None
}
};
match context {
EditContext::Lanes => {
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
}
EditContext::Messages => {
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
return None;
};
let occurrence = reference(&address.get("occurrence")?.value)?;
let path: Vec<_> = match address.get("references") {
None => Vec::new(),
Some(field) => {
let EditKind::List(path) = &field.value.kind else {
return None;
};
path.iter().map(reference).collect::<Option<Vec<_>>>()?
}
};
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
}
_ => None,
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
if matches!(context, EditContext::Lanes | EditContext::Messages) {
return scenario_item_key(value, aliases, context);
}
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
@@ -68,6 +68,11 @@ pub(super) fn upgrade(
&actual.fields,
Some(&actual.fields),
&next.fields,
if project.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
},
edits.entry(path.clone()).or_default(),
);
}
-10
View File
@@ -449,13 +449,6 @@ 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(),
@@ -615,9 +608,6 @@ 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))); }
+1 -12
View File
@@ -180,18 +180,6 @@ 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);
@@ -631,6 +619,7 @@ pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
+1
View File
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+1
View File
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+1
View File
@@ -18,6 +18,7 @@ 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"
File diff suppressed because it is too large Load Diff
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
if !d.projection.is_architecture()
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
@@ -208,11 +208,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
d.projection.is_architecture()
&& p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
@@ -257,6 +258,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
if matches!(
&event,
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
) && !self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection.is_architecture())
{
self.status = "Choose an architecture view before adding architecture content.".into();
return Task::none();
}
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
@@ -70,6 +70,7 @@ 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,6 +225,7 @@ 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(),
@@ -299,6 +300,7 @@ 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,6 +214,11 @@ 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,
+161
View File
@@ -0,0 +1,161 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::behavior::scenario_layout::Representation;
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)) => (
match c.representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
},
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch
&& self.pending_scenario_reveal_visit == Some(c.visit)
&& self.page == Page::Behavior
&& self.behavior.active_owner == Some(c.scenario)
&& self.behavior.scenario.context(
self.canvas_document,
c.scenario,
self.document.revision(),
self.canvas_epoch,
) == Some(*c),
),
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::SourceRenameReady(_)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = 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;
self.pending_scenario_reveal_visit = None;
}
}
@@ -0,0 +1,418 @@
//! 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(),
layout: None,
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,
visit: Id,
) -> 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(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
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, Id) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(
*context,
*epoch,
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
)
}
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(
app.project(),
target.surface.id(),
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, visit) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
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, visit),
);
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, visit) = 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, visit),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch, visit));
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, visit),
);
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, visit) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch, current_visit) = stamp(&app);
assert_eq!(current_visit, visit);
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, visit),
);
assert_eq!(stamp(&app), (current, epoch, visit));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
);
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, visit) = 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, visit),
);
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());
}
}
}
}
+535
View File
@@ -0,0 +1,535 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
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.source_rename.open()
&& 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.pending_scenario_reveal_visit = 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()
}
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
/// never selects the owner or another saved arrangement as a fallback.
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
return Task::none();
}
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
{
return Task::none();
}
let Some(found) = self
.current_surface_usages(selected, Surface::Saved(diagram))
.into_iter()
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
else {
self.status = "This object has no visible target in that saved Scenario.".into();
return Task::none();
};
self.reveal_diagram_usage(&found);
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 scenario_representation = match usage.surface {
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
Some(Representation::Saved { diagram })
}
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
_ => None,
};
if let Some(representation) = scenario_representation {
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
return false;
};
if !self
.behavior
.scenario
.representation_usages(self.project(), representation, usage.subject)
.iter()
.any(|row| row == usage)
{
return false;
}
// Finish target/camera admission before changing the App route. The
// checked immutable owner also makes the following entry infallible.
let revealed = match representation {
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
self.document.project(),
self.canvas_document,
owner,
&usage.target,
),
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
self.document.project(),
self.canvas_document,
owner,
representation,
&usage.target,
),
};
if !revealed {
return false;
}
if !self.enter_scenario_representation(representation) {
return false;
}
}
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(owner) = diagram.projection.scenario() {
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
} else 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) => {
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
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_scenario_reveal_visit = scenario_representation.and_then(|representation| {
let owner = presentation::checked_owner(self.project(), representation)?;
self.behavior
.scenario
.context(
self.canvas_document,
owner,
self.document.revision(),
self.canvas_epoch,
)
.map(|context| context.visit)
});
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)
&& (!self.saved_scenario_diagram(id)
|| (self.page == Page::Behavior
&& self.behavior.scenario.representation()
== Some(Representation::Saved { diagram: id })))
}
Surface::Process(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
Surface::Scenario(owner) => {
self.page == Page::Behavior
&& self.behavior.active_owner == Some(owner)
&& self.behavior.scenario.representation()
== Some(Representation::Derived { 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
@@ -0,0 +1,108 @@
arcadia 7;
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
// Two operational activities, with one lifeline appearance per Scenario.
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
source @report_incident;
target @assess_incident;
}
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
viewpoint OperationalActivity;
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
}
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
parent @incident_response; represented @report_incident;
}
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
parent @incident_response; represented @assess_incident;
}
// Both appearances reuse one definition. Navigation must retain the occurrence.
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
// A correlated self call/reply with deliberately unassigned exchange definitions.
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
}
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
}
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
}
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
parent @incident_response;
body { ScenarioUse {
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
scenario @reporting_review;
}; };
}
// This definition stays opaque in the caller. Open it, then Back to the saved view.
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
viewpoint OperationalActivity;
steps [@review_delivery];
}
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
parent @reporting_review; represented @report_incident;
}
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
parent @reporting_review; represented @assess_incident;
}
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
parent @reporting_review;
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
}
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
// These valid authored lanes must remain saved during complete fallback.
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
messages [
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
{ address { references []; occurrence @assess_reply; }; send_y 300; },
// This authored value stays latent while the receiver is missing.
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
];
}; };
}
}
@@ -0,0 +1,356 @@
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
use super::*;
use crate::behavior::scenario::{CanvasEvent, State};
use crate::behavior::scenario_layout::{
Representation,
presentation::{self, PresentationStatus},
};
use iced::{Size, Vector};
fn fixture() -> App {
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
let errors: Vec<_> = oa_core::validation::validate(&p)
.into_iter()
.filter(|d| d.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
App::with_project(p)
}
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn representation(view: Id) -> Representation {
Representation::Saved { diagram: view }
}
fn find(app: &App, subject: Id, view: Id) -> Usage {
usage::saved_usages_for(app.project(), subject, Some(view))
.into_iter()
.find(|row| row.target == Target::ScenarioObject(subject))
.expect("exact saved occurrence")
}
fn context(app: &App) -> behavior::scenario::Context {
let owner = app.behavior.active_owner.unwrap();
app.behavior
.scenario
.context(
app.canvas_document,
owner,
app.document.revision(),
app.canvas_epoch,
)
.unwrap()
}
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
let (token, rows) = open(app, subject);
let found = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
.expect("saved chooser destination");
choose(app, token, found);
assert!(app.diagram_usages.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(view));
assert_eq!(
app.behavior.scenario.representation(),
Some(representation(view))
);
assert_eq!(app.selected, subject);
token
}
#[test]
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
{
let app = fixture();
let owner = id(&app, "incident_response");
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
let exchange = id(&app, "incident_report");
let latent = id(&app, "unfinished_notice");
let callee_message = id(&app, "review_delivery");
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
let derived = app
.behavior
.scenario
.diagram_usages(app.project(), owner, first)
.pop()
.unwrap();
let rows = views.map(|view| find(&app, first, view));
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
assert_ne!(rows[0].bounds, rows[1].bounds);
assert_eq!(
rows[2].bounds, derived.bounds,
"complete fallback cannot retain the authored lane override"
);
for view in views {
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
assert_eq!(represented.len(), 2);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(first))
);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(second))
);
assert!(
represented
.iter()
.all(|u| u.surface == Surface::Saved(view))
);
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
assert_eq!(
find(&app, latent, view).target,
Target::ScenarioObject(latent)
);
let shown = presentation::present(app.project(), representation(view), owner);
let whole = usage::saved_usages_for(app.project(), view, Some(view));
assert_eq!(whole.len(), 1);
assert_eq!(whole[0].target, Target::Whole);
assert_eq!(Some(whole[0].bounds), shown.bounds());
}
let wide = presentation::present(app.project(), representation(views[1]), owner)
.bounds()
.unwrap();
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
let fallback = presentation::present(app.project(), representation(views[2]), owner);
assert!(matches!(
fallback.status(),
PresentationStatus::Fallback { .. }
));
assert_eq!(fallback.latent_messages(), &[latent]);
assert!(!fallback.capabilities().coordinate_input);
}
#[test]
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
let mut app = fixture();
let view = id(&app, alias);
let first = id(&app, "initial_report");
let owner = id(&app, "incident_response");
let original = app.document.snapshot();
app.document
.execute(Command::Rename(first, "Reviewed incident report".into()))
.unwrap();
let edited = app.document.snapshot();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
navigate(&mut app, first, view);
assert_eq!(app.behavior.active_owner, Some(owner));
assert_eq!(authored(&app), before);
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(app.active_diagram, Some(view));
assert_eq!(authored(&app), before);
assert!(app.document.undo());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.redo());
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.bundle(), &edited.bundle);
}
}
#[test]
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
for chooser in [false, true] {
for block in 0..3 {
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let choice = chooser.then(|| {
let (token, rows) = open(&mut app, first);
(
token,
rows.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap(),
)
});
match block {
0 => {
let cursor = app.source_editor.cursor();
app.source_workspace
.edit("? preserve unfinished source".into(), cursor);
}
1 => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied inspector value".into());
assert!(app.draft_dirty());
}
_ => {
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
}
let before = authored(&app);
let prior = location(&app);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
if let Some((token, row)) = choice {
choose(&mut app, token, row);
} else {
send(&mut app, Message::RevealInDiagram(view, first));
}
assert_eq!(authored(&app), before);
assert_eq!(location(&app), prior);
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
let mut app = fixture();
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let first = id(&app, "initial_report");
let before = authored(&app);
let old_token = navigate(&mut app, first, a);
let old = context(&app);
navigate(&mut app, first, b);
navigate(&mut app, first, a);
let current = context(&app);
assert_ne!(old.visit, current.visit);
let held = behavior::scenario::Context {
epoch: current.epoch,
..old
};
let camera = app.behavior.scenario.camera_boundary();
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(&mut app, Message::ScenarioCanvas(CanvasEvent::Focus(held)));
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
);
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
assert!(app.pending_diagram_reveal.is_some());
assert!(app.behavior.scenario.camera_boundary() == camera);
let bounds = find(&app, first, a).bounds;
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(context(&app).visit, current.visit);
let mut expected = State::default();
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
expected.viewport(Size::new(500., 300.));
expected.zoom(Some(1.25));
expected.camera(
app.project(),
current.scenario,
behavior::canvas_workspace::Event::Pan(Vector::new(
250. - bounds.center_x() * 1.25,
150. - bounds.center_y() * 1.25,
)),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
assert_eq!(authored(&app), before);
}
#[test]
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
let mut app = fixture();
let first = id(&app, "initial_report");
let view = id(&app, "incident_readable");
let other = id(&app, "reporting_review");
let (token, rows) = open(&mut app, first);
let old = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap();
let mut replacement = app.project().diagrams[&view].clone();
replacement.projection = ProjectionKind::Scenario { scenario: other };
replacement.layout = None;
app.document
.execute(Command::EditDiagram(view, replacement))
.unwrap();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
let prior = location(&app);
choose(&mut app, token, old);
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
finish_lookup(&mut app);
let token = app.diagram_usages.as_ref().unwrap().token;
assert!(
app.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.all(|u| u.surface != Surface::Saved(view))
);
send(&mut app, Message::DiagramUsage(Event::Close(token)));
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
#[test]
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
{
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let reply = id(&app, "assess_reply");
let before = app.document.snapshot();
let fallback = find(&app, first, view);
app.document
.execute(Command::SetScenarioMessagePosition {
diagram: view,
position: oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: reply,
},
send_y: 424.,
},
})
.unwrap();
app.sync_source();
app.refresh_draft();
let repaired = app.document.snapshot();
let authored_row = find(&app, first, view);
assert_ne!(authored_row.bounds, fallback.bounds);
navigate(&mut app, first, view);
assert!(app.document.undo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
assert!(app.document.redo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &repaired.decoded.project);
assert_eq!(app.document.bundle(), &repaired.bundle);
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
}
+16
View File
@@ -213,6 +213,14 @@ 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,
@@ -345,6 +353,14 @@ 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,
+80 -35
View File
@@ -15,18 +15,15 @@ impl App {
epoch: self.canvas_epoch,
})
}
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 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 edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
@@ -52,6 +49,48 @@ 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,
scenario_view: None,
};
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();
}
@@ -81,6 +120,7 @@ impl App {
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
@@ -118,14 +158,18 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.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.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
@@ -149,7 +193,9 @@ impl App {
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -524,32 +570,30 @@ mod tests {
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
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;
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::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
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.scroll_offset(),
iced::Vector::new(25., 140.)
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
@@ -557,12 +601,12 @@ mod tests {
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
@@ -582,6 +626,7 @@ 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));
@@ -0,0 +1,304 @@
//! 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;
@@ -0,0 +1,586 @@
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());
}
@@ -0,0 +1,910 @@
//! 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());
}
@@ -0,0 +1,263 @@
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();
@@ -0,0 +1,281 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, CanvasEvent::Focus);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(!app.behavior.scenario.focused());
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -0,0 +1,96 @@
//! Saved Scenario navigation is presentation state, never a document edit.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
impl App {
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
let Some(owner) = checked_owner(self.project(), representation) else {
self.status =
"This Scenario view is no longer available or has an incompatible owner.".into();
return false;
};
self.layer = self.project().elements[&owner].layer();
self.page = Page::Behavior;
self.behavior.active_owner = Some(owner);
match representation {
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
Representation::Derived { .. } => {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
self.active_diagram = None;
}
}
}
self.selected_node = None;
if self
.behavior
.scenario
.enter_representation(self.canvas_document, owner, representation)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
true
}
/// Reconcile after navigation/adoption without selecting or applying a draft.
/// A retained invalid Saved identity remains unavailable; it must not silently
/// turn into a different automatic Scenario or another saved arrangement.
pub(super) fn reconcile_scenario_workspace(&mut self) {
if self.page == Page::Diagram
&& !self.behavior.has_pending()
&& let Some(diagram) = self
.active_diagram
.filter(|id| self.saved_scenario_diagram(*id))
{
self.enter_scenario_representation(Representation::Saved { diagram });
}
if self.page != Page::Behavior {
self.behavior.scenario.leave();
return;
}
if let Some(rep @ Representation::Saved { diagram }) =
self.behavior.scenario.representation()
{
// The requested view is authoritative until explicit navigation
// retires it. This also recovers the same view when Undo restores it.
if let Some(owner) = checked_owner(self.project(), rep) {
self.active_diagram = Some(diagram);
self.layer = self.project().elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
} else if let Some(owner) = self.behavior.scenario.owner() {
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
}
return;
}
let Some(owner) = behavior::selected_owner(self) else {
self.behavior.scenario.leave();
return;
};
if self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.behavior.scenario.enter(self.canvas_document, owner);
} else {
self.behavior.scenario.leave();
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod adoption_tests;
#[cfg(test)]
mod owner_navigation_tests;
@@ -0,0 +1,264 @@
//! Independent adoption oracles: expected Projects are edited directly in the
//! fixture, then admitted through the actual source editor/Document transaction.
//! Navigation is checked separately from the expected authored mutation.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
use iced::widget::text_editor;
use oa_core::language;
use std::sync::Arc;
struct Fixture {
app: App,
owner: Id,
same_layer: Id,
other_layer: Id,
view: Id,
spare_view: Id,
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
.unwrap();
let same_layer = project
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
.unwrap();
let other_layer = project
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
.unwrap();
// A remaining topology makes silent fallback observably different from the
// required unavailable Saved view after its owner is deleted.
project
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
.unwrap();
let mut make_view = |alias: &str, name: &str, owner| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
id
};
let view = make_view("retained_view", "Retained arrangement", owner);
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
choose(&mut app, view);
assert_saved(&app, view, owner, Layer::Oa);
assert!(!app.document.can_undo());
Fixture {
app,
owner,
same_layer,
other_layer,
view,
spare_view,
}
}
fn choose(app: &mut App, id: Id) {
let _ = app.update(Message::Diagram(Choice {
id,
label: "Previously captured picker item".into(),
}));
}
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.active_diagram, Some(view));
let context = app
.scenario_context()
.expect("saved owner remains the visible context");
assert_eq!(context.scenario, owner);
assert_eq!(
context.representation,
Representation::Saved { diagram: view }
);
assert!(!context.visit.is_nil());
}
fn adopt_from_source(app: &mut App, expected: &Project) {
let text = language::format(expected);
// Parser equality is a fixture precondition, not the navigation oracle.
assert_eq!(language::parse(&text).unwrap(), *expected);
let _ = app.update(Message::Page(Page::Text));
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)),
)));
assert!(app.source_dirty());
let _ = app.update(Message::ApplySource);
assert!(!app.source_dirty(), "{}", app.status);
assert_eq!(
app.project(),
expected,
"actual source transaction must adopt exactly the fixture change"
);
let _ = app.update(Message::Page(Page::Behavior));
}
fn retarget(cross_layer: bool) {
let mut f = fixture();
let before = f.app.document.snapshot();
let old_context = f.app.scenario_context().unwrap();
let revision = f.app.document.revision();
let target = if cross_layer {
f.other_layer
} else {
f.same_layer
};
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.view).unwrap();
view.projection = ProjectionKind::Scenario { scenario: target };
view.layer = layer;
adopt_from_source(&mut f.app, &expected);
assert_saved(&f.app, f.view, target, layer);
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
assert_eq!(
f.app.document.revision(),
revision + 1,
"routing must not create a second transaction"
);
assert_eq!(
f.app.project().diagrams[&f.spare_view],
before.decoded.project.diagrams[&f.spare_view]
);
let adopted_bundle = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(
!f.app.document.can_undo(),
"exactly one authored adoption must be undone"
);
let _ = f.app.update(Message::Redo);
assert_saved(&f.app, f.view, target, layer);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted_bundle);
assert!(!f.app.document.can_redo());
}
#[test]
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(false);
}
#[test]
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(true);
}
#[tokio::test]
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
assert!(expected.elements.remove(&f.owner).is_some());
assert!(expected.diagrams.remove(&f.view).is_some());
assert!(expected.elements.contains_key(&f.same_layer));
assert!(
expected
.elements
.values()
.any(|e| e.kind() == Kind::Process)
);
adopt_from_source(&mut f.app, &expected);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
let adopted = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut mounted = Mounted::new(&mut f.app).await;
let geometry = mounted.geometry(&f.app);
assert!(
geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert!(
!geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
assert_eq!(
f.app.document.revision(),
revision,
"mounting unavailable must remain passive"
);
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.document.bundle(), &before.bundle);
let _ = f.app.update(Message::Redo);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
mounted.settle(&mut f.app).await;
assert!(
mounted
.geometry(&f.app)
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
}
#[test]
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
let mut f = fixture();
let mut expected = f.app.project().clone();
expected.diagrams.remove(&f.spare_view).unwrap();
adopt_from_source(&mut f.app, &expected);
let _ = f.app.update(Message::Select(f.owner));
let _ = f.app.update(Message::Field(
"/name".into(),
"Pending valid owner rename".into(),
));
assert!(f.app.draft_dirty());
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let selected = f.app.selected;
for invalid in [Id::nil(), f.spare_view] {
choose(&mut f.app, invalid);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.selected, selected);
assert!(f.app.draft_dirty());
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
}
// Establish that the retained draft really was valid, not merely refused by
// the property validator: explicit Apply commits it in exactly one step.
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(
f.app.project().elements[&f.owner].name,
"Pending valid owner rename"
);
assert_eq!(f.app.document.revision(), revision + 1);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
@@ -0,0 +1,113 @@
//! Explicit owner navigation must retire a retained Saved Scenario, including
//! when selection was made on a different page. These are real App events.
use super::*;
use crate::behavior::scenario::CanvasEvent;
fn setup(kind: Kind) -> (App, Id, Id) {
let mut project = Project::empty();
let scenario = project
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
.unwrap();
let target = project
.add_element(kind, Layer::Oa, "Explicit destination", None)
.unwrap();
let view = Id::new_v4();
project.diagrams.insert(
view,
Diagram {
id: view,
alias: "incident_saved".into(),
name: "Incident saved arrangement".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
let errors: Vec<_> = oa_core::validation::validate(&project)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
let mut app = App::with_project(project);
let _ = app.update(Message::Select(scenario));
let _ = app.update(Message::Diagram(Choice {
id: view,
label: "Incident saved arrangement".into(),
}));
assert_eq!(app.page, Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: view }
);
assert_eq!(app.selected, scenario);
(app, scenario, target)
}
fn assert_destination(app: &App, target: Id) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, target);
assert_eq!(app.behavior.active_owner, Some(target));
assert_eq!(behavior::selected_owner(app), Some(target));
assert_eq!(
app.behavior.scenario.representation(),
None,
"explicit non-Scenario destination must not retain an overriding Saved request"
);
assert!(app.scenario_context().is_none());
}
fn exercise(via_guide: bool) {
for kind in [Kind::Process, Kind::StateMachine] {
let (mut app, scenario, target) = setup(kind);
assert_ne!(scenario, target);
let held = app.scenario_context().unwrap();
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
assert!(!app.document.can_undo() && !app.document.can_redo());
if via_guide {
let _ = app.update(Message::Page(Page::Guide));
assert_eq!(app.page, Page::Guide);
assert!(app.scenario_context().is_none());
}
let _ = app.update(Message::Select(target));
if via_guide {
assert_eq!(
app.page,
Page::Guide,
"explorer selection must not itself switch the page"
);
let _ = app.update(Message::Page(Page::Behavior));
}
assert_destination(&app, target);
let camera = app.camera_boundary();
// The prior saved canvas cannot regain focus after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Focus(held)));
assert_destination(&app, target);
assert!(app.camera_boundary() == camera);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.document.dirty(), app.dirty(), app.source_dirty()),
dirty
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
exercise(true);
}
#[test]
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
exercise(false);
}
@@ -0,0 +1,205 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", p.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn setup() -> (App, Id, Id, Id, Id) {
let (mut p, [owner, callee, _], [reference, _, _]) =
behavior::scenario::direct_tests::navigation_fixture();
let a = saved(&mut p, owner, "Review A");
let b = saved(&mut p, owner, "Review B");
let mut app = App::with_project(p);
app.select(owner);
(app, a, b, reference, callee)
}
fn choose(app: &mut App, diagram: Id) {
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Captured view".into(),
}));
}
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(f(context)));
}
#[test]
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
let (mut app, a, b, _, _) = setup();
let original = app.document.snapshot();
let owner = app.selected;
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
let edited = app.document.snapshot();
let edited_revision = app.document.revision();
choose(&mut app, a);
let first = app.scenario_context().unwrap();
choose(&mut app, b);
let second = app.scenario_context().unwrap();
choose(&mut app, a);
let third = app.scenario_context().unwrap();
assert_eq!(first.scenario, owner);
assert_eq!(first.representation, Representation::Saved { diagram: a });
assert_eq!(second.representation, Representation::Saved { diagram: b });
assert_ne!(first.visit, second.visit);
assert_ne!(first.visit, third.visit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, owner);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.revision(), edited_revision);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
}
#[test]
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
choose(&mut app, b);
choose(&mut app, a);
let current = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let before = app.document.snapshot();
for stale in [
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
CanvasEvent::FormShown(old, Id::new_v4()),
CanvasEvent::Restored(
old,
Id::new_v4(),
ScrollArea::Page,
iced::Vector::new(0., 800.),
),
CanvasEvent::Camera(
old,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
),
] {
let _ = app.update(Message::ScenarioCanvas(stale));
assert_eq!(app.scenario_context(), Some(current));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
#[test]
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
let (mut app, a, _, reference, callee) = setup();
choose(&mut app, a);
let caller = app.scenario_context().unwrap();
let before = app.document.snapshot();
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
let target = app.scenario_context().unwrap();
assert_eq!(target.scenario, callee);
assert_eq!(
target.representation,
Representation::Derived { owner: callee }
);
assert_eq!(app.active_diagram, None);
event(&mut app, CanvasEvent::Back);
let returned = app.scenario_context().unwrap();
assert_eq!(returned.representation, caller.representation);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(app.selected, reference);
assert_ne!(returned.visit, caller.visit);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
#[test]
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let _ = app.update(Message::Select(a));
let before = app.document.snapshot();
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
choose(&mut app, b);
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
assert_eq!(app.selected, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
choose(&mut app, a);
let replacement = Id::new_v4();
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, a);
assert!(!app.project().diagrams.contains_key(&a));
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(app.selected, replacement);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
}
#[test]
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
let (mut app, a, _, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::Page(Page::Guide));
assert!(app.scenario_context().is_none());
let _ = app.update(Message::Page(Page::Behavior));
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.representation, old.representation);
assert_ne!(fresh.visit, old.visit);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let stale = app.scenario_context().unwrap();
choose(&mut app, b);
let current = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
stale,
Representation::Saved { diagram: a },
)));
assert_eq!(app.scenario_context(), Some(current));
event(&mut app, |context| {
CanvasEvent::Representation(
context,
Representation::Derived {
owner: context.scenario,
},
)
});
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived {
owner: current.scenario
}
);
assert_eq!(app.active_diagram, None);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
@@ -0,0 +1,88 @@
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
use super::*;
use behavior::scenario::spacing::{Event, controller::Finish};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn spacing_enabled(&self) -> bool {
self.pending_diagram_reveal.is_none()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.source_rename.open()
&& behavior::canvas_workspace::keyboard_enabled(self)
}
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
let stamp = event.stamp();
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
{
return Task::none();
}
let state = &self.behavior.scenario;
let matches =
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
if match event {
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
_ => !matches,
} {
return Task::none();
}
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
match event {
Event::Begin { .. } => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_spacing(self.project(), event);
self.behavior.scenario = state;
if result.is_err() {
self.status = "The drag could not start in this view. Try again.".into();
}
}
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
Event::Finish { screen, .. } => {
let finished = self.behavior.scenario.finish_spacing(screen);
// Retire queued packets even for zero motion or a refused finish.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
match finished {
Ok(Finish::Click(target)) => self.select(target),
Ok(Finish::Noop) => {}
Ok(Finish::Commit {
command,
selected,
scale,
pan,
}) => {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
self.status = "Scenario spacing updated.".into();
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
}
}
Err(_) => {
self.status =
"That position cannot be applied. The saved arrangement is unchanged."
.into()
}
}
}
Event::Cancel { .. } => unreachable!(),
}
Task::none()
}
}
@@ -0,0 +1,272 @@
use super::*;
use behavior::scenario::{
CanvasEvent,
spacing::{
Target,
controller::tests::{fixture, packet},
},
};
use iced::{Point, Size, Vector};
fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let c = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
c,
Size::new(900., 600.),
)));
assert!(app.spacing_enabled());
(app, lane, message)
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
let Event::Begin {
press_screen,
scale,
..
} = event
else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
#[test]
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let source = app.source_workspace.bundle();
let selected = app.selected;
let epoch = app.canvas_epoch;
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, selected);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
finish(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, lane);
assert!(app.document.can_undo() && !app.document.can_redo());
let accepted = app.document.snapshot();
assert_ne!(accepted.bundle, original.bundle);
let expected = behavior::scenario::spacing::controller::tests::expected(
&original.decoded.project,
match event.stamp().context.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
},
Target::Lane(lane),
40. / scale,
);
assert_eq!(app.project(), &expected);
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
#[test]
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
let (mut app, lane, message) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(message));
finish(&mut app, event, start + Vector::new(30., 2.));
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
for cancel in [false, true] {
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if cancel {
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
} else {
finish(&mut app, event, start);
}
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
let (mut app, lane, _) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let held_epoch = app.canvas_epoch;
send(
&mut app,
Event::Finish {
stamp: event.stamp(),
token: Id::new_v4(),
screen: start + Vector::new(30., 0.),
},
);
assert_eq!(app.canvas_epoch, held_epoch);
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
let owner = event.stamp().context.scenario;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
event.stamp().context,
behavior::scenario_layout::Representation::Derived { owner },
)));
let current = app.scenario_context();
let epoch = app.canvas_epoch;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.scenario_context(), current);
assert_eq!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
let (mut app, lane, _) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selected = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 0.));
finish(&mut app, event, start + Vector::new(40., 0.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.selected, selected);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
#[test]
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
for source in [false, true] {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let selected = app.selected;
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if source {
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Insert(' '),
)));
assert!(app.source_dirty());
} else {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
behavior::scenario::Event::Start(
event.stamp().context.scenario,
behavior::scenario::Tool::Lifeline,
),
)));
assert!(app.behavior.has_pending());
}
let draft = app.source_workspace.bundle();
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.source_workspace.bundle(), draft);
assert_eq!(app.selected, selected);
assert!(!app.document.can_undo());
}
}
#[test]
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
let (mut app, lane, message) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let behavior::scenario_layout::Representation::Saved { diagram } =
event.stamp().context.representation
else {
unreachable!()
};
let _ = app.update(Message::RevealInDiagram(diagram, message));
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.canvas_epoch;
let selected = app.selected;
let visit = app.pending_scenario_reveal_visit;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.pending_scenario_reveal_visit, visit);
assert!(app.pending_diagram_reveal.is_some());
assert_eq!(app.selected, selected);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::Delete);
assert!(app.delete_pending);
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::CancelDelete);
send(&mut app, event);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Keep Iced's single-line input from silently filtering pasted control bytes.
//! Only the alias field uses this adapter. The child retains editing and focus.
use iced::advanced::{
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
};
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[derive(Default)]
struct State {
modifiers: keyboard::Modifiers,
}
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
// previously admitted paste while V is held, until KeyReleased clears that cache.
struct CapturedClipboard<'a> {
content: Option<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
if kind == clipboard::Kind::Standard {
self.content.clone()
} else {
self.original.read(kind)
}
}
fn write(&mut self, kind: clipboard::Kind, contents: String) {
self.original.write(kind, contents);
}
}
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.input.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.input));
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.input
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.input.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.input
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let focused = tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
}
let paste = matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
if key.to_latin(*physical_key) == Some('v')
&& state.modifiers.command() && !state.modifiers.alt());
if focused
&& paste
&& let Some(rejected) = &self.rejected
{
let content = clipboard.read(clipboard::Kind::Standard);
if content
.as_ref()
.is_some_and(|value| value.chars().any(char::is_control))
{
// Do not invoke the child: even an empty replacement would erase
// its selection. Approval invalidation is an explicit App event.
shell.publish(rejected.clone());
shell.capture_event();
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
&mut CapturedClipboard {
content,
original: clipboard,
},
shell,
viewport,
);
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
// Escape branch, which also resets stored modifiers. Observe that branch
// after its text/shortcut early returns and effective-key conversion.
if focused
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
&& !tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.input.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -0,0 +1,220 @@
use super::*;
use crate::views::source_tests::FontLayout;
use iced::keyboard::{
Key, Modifiers,
key::{Code, Named, Physical},
};
use std::cell::Cell;
#[derive(Debug, Clone, PartialEq)]
enum Signal {
Input(String),
Rejected,
}
struct ClipboardText {
text: String,
reads: Cell<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
self.reads.set(self.reads.get() + 1);
Some(self.text.clone())
}
fn write(&mut self, _: clipboard::Kind, _: String) {}
}
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
impl Mounted {
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
let mut child = iced::widget::text_input("Alias", "send_water");
if enabled {
child = child.on_input(Signal::Input);
}
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
let mut tree = widget::Tree::new(input.as_widget());
let node = input.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
);
if focused {
let state = tree.children[0].state.downcast_mut::<InputState>();
state.focus();
state.select_range(0, 10);
}
let mut mounted = Self {
input,
tree,
node,
clipboard: ClipboardText {
text: "foo\nbar".into(),
reads: Cell::new(0),
},
messages: vec![],
};
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)));
mounted
}
fn state(&self) -> &InputState {
self.tree.children[0].state.downcast_ref::<InputState>()
}
fn event(&mut self, event: Event) -> bool {
let mut shell = Shell::new(&mut self.messages);
self.input.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Unavailable,
&FontLayout,
&mut self.clipboard,
&mut shell,
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
);
shell.is_event_captured()
}
}
fn key(key: Key, modified_key: Key, code: Code) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modified_key,
physical_key: Physical::Code(code),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
})
}
fn paste() -> Event {
key(
Key::Character("v".into()),
Key::Character("v".into()),
Code::KeyV,
)
}
#[test]
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
for focused in [false, true] {
for enabled in [false, true] {
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::COMMAND,
Modifiers::COMMAND | Modifiers::ALT,
Modifiers::COMMAND | Modifiers::SHIFT,
] {
let mut mounted = Mounted::new(focused, enabled, modifiers);
let before = mounted.state().cursor();
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
let captured = mounted.event(paste());
assert_eq!(
mounted.messages,
if expected {
vec![Signal::Rejected]
} else {
vec![]
}
);
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
assert_eq!(mounted.state().cursor(), before);
if expected {
assert!(captured);
}
}
}
}
}
#[test]
fn effective_escape_reset_follows_child_after_early_returns() {
// Raw Escape mapped to Home must not clear only the guard's Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Escape),
Key::Named(Named::Home),
Code::Escape,
));
assert!(mounted.state().is_focused());
assert!(mounted.event(paste()));
assert_eq!(mounted.messages, vec![Signal::Rejected]);
// Effective Escape clears both states; a later focus operation does not
// invent a new ModifiersChanged event or revive the previous Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
));
assert!(!mounted.state().is_focused());
mounted.tree.children[0]
.state
.downcast_mut::<InputState>()
.focus();
mounted.event(paste());
assert!(mounted.messages.is_empty());
assert_eq!(mounted.clipboard.reads.get(), 0);
// Printable text returns before effective Escape and leaves modifiers live.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
let mut event = key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
);
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
*text = Some("x".into());
}
mounted.event(event);
assert!(mounted.state().is_focused());
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
mounted.messages.clear();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
}
#[test]
fn held_paste_cache_contains_only_previously_admitted_text() {
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.clipboard.text = "water".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
mounted.messages.clear();
mounted.clipboard.text = "other".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwater".into())],
"Iced reuses its first admitted paste while the key is held"
);
mounted.messages.clear();
let before = mounted.state().cursor();
mounted.clipboard.text = "bad\tvalue".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
assert_eq!(mounted.state().cursor(), before);
mounted.messages.clear();
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
key: Key::Character("v".into()),
modified_key: Key::Character("v".into()),
physical_key: Physical::Code(Code::KeyV),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
}));
mounted.clipboard.text = "later".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwaterlater".into())]
);
}
@@ -0,0 +1,303 @@
//! Display-only reuse after a guarded alias rename. This is not model authority.
use iced::{advanced::text::Renderer, widget::text_editor};
use std::sync::Arc;
/// Replace exact, unchanged-line-count source in an existing editor.
///
/// The caller must first authorize/commit the model change and must reconstruct
/// from accepted source on false. Preflight failures do not mutate the editor;
/// a failed final readback can leave it changed. On changing success the cursor
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
/// Direct Content actions deliberately bypass App draft edits and Undo history.
pub(super) fn replace<R: Renderer>(
editor: &mut text_editor::Content<R>,
before: &str,
after: &str,
) -> bool {
if editor.text() != before {
return false;
}
let Some(old_lines) = lines(before) else {
return false;
};
let Some(new_lines) = lines(after) else {
return false;
};
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
return false;
}
let mut patches = Vec::new();
for (line, ((old, old_ending), (new, new_ending))) in
old_lines.into_iter().zip(new_lines).enumerate()
{
let Some(actual) = editor.line(line) else {
return false;
};
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
return false;
}
if old == new {
continue;
}
let mut start = old
.bytes()
.zip(new.bytes())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
start -= 1;
}
let mut suffix = old[start..]
.bytes()
.rev()
.zip(new[start..].bytes().rev())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
{
suffix -= 1;
}
// All positions and owned payloads are prepared before the first edit.
patches.push((
line,
start,
old.len() - suffix,
Arc::new(new[start..new.len() - suffix].to_owned()),
));
}
if patches.is_empty() {
return true;
}
let empty = Arc::new(String::new());
for (line, start, end, replacement) in patches.into_iter().rev() {
select(editor, line, start, end);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
replacement,
)));
}
// Iced overwrites its earliest-change marker on each Edit. An explicit
// zero-width selection + empty Paste changes no bytes, but marks line zero.
// This also refreshes previously unhighlighted lines before the first patch.
// It resets only line zero's text cache; other unchanged lines retain theirs.
select(editor, 0, 0, 0);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
editor.text() == after
}
fn select<R: Renderer>(
editor: &mut text_editor::Content<R>,
line: usize,
start: usize,
end: usize,
) {
editor.move_to(text_editor::Cursor {
position: text_editor::Position {
line,
column: start,
},
// move_to(None) does not clear a previous selection in this Iced version.
selection: Some(text_editor::Position { line, column: end }),
});
}
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
use text_editor::LineEnding;
let mut result = Vec::new();
let mut parts = source.split('\n').peekable();
while let Some(part) = parts.next() {
let (text, ending) = if parts.peek().is_none() {
(part, LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, LineEnding::CrLf)
} else {
(part, LineEnding::Lf)
};
if text.contains('\r')
|| (text.is_empty()
&& ending == LineEnding::CrLf
&& result
.last()
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
{
return None;
}
result.push((text, ending));
}
Some(result)
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{layout, text::Highlighter, widget::Tree};
use iced::{Element, Font, Length, Size};
use std::{cell::RefCell, ops::Range};
#[test]
fn exact_text_matrix_and_reverse_preserve_endings() {
for (before, after) in [
("", ""),
("", "alias"),
("alias", ""),
("same", "same"),
("a\n", "alias\n"),
("a\r\n", "alias\r\n"),
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
("\na\n\n", "\nlong_alias\n\n"),
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
(
"雨🙂 e\u{301} @a @a // a",
"雨🙂 e\u{301} @a_long @a_long // a",
),
("@abc", "@abcdef"),
("@abcdef", "@abc"),
("@abc_suffix", "@def_suffix"),
("@none", "@identifier"),
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
// not be used as String split positions, even outside alias scope.
("雨xé", "雪xê"),
("é", "©"),
(
"unchanged\n@a\nunchanged\n@a\n",
"unchanged\n@long\nunchanged\n@long\n",
),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::with_text(after);
assert_eq!(editor.line_count(), expected.line_count());
for (actual, expected) in editor.lines().zip(expected.lines()) {
assert_eq!(actual.text, expected.text);
assert_eq!(actual.ending, expected.ending);
}
if before != after {
assert_eq!(
editor.cursor().position,
text_editor::Position { line: 0, column: 0 }
);
assert_eq!(editor.cursor().selection, None);
}
assert!(replace(&mut editor, after, before));
assert_eq!(editor.text(), before);
}
}
#[test]
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
for (actual, before, after) in [
("live", "stale", "changed"),
("a\nb", "a\nb", "long\nb\nc"),
("a\nb", "a\nb", "long\r\nb"),
// Refusal on a later line must not apply the earlier planned edit.
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
("a\rb", "a\rb", "long\rb"),
("a\n\rb", "a\n\rb", "long\n\rb"),
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
// The replacement must not introduce a greedy LFCR boundary either.
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
("a", "a", "long\r"),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
select(&mut editor, 0, 0, 1);
let cursor = editor.cursor();
let selection = editor.selection();
assert!(
!replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), actual);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.selection(), selection);
}
}
#[test]
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
select(&mut editor, 0, 1, 2);
let cursor = editor.cursor();
assert!(replace(&mut editor, "@a", "@a"));
assert_eq!(editor.cursor(), cursor);
assert!(replace(&mut editor, "@a", "@long"));
editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 5 },
selection: None,
});
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
assert_eq!(editor.text(), "@long!");
}
thread_local! {
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
fn new(settings: &Self::Settings) -> Self {
Self(crate::highlight::ArcadiaHighlighter::new(settings))
}
fn update(&mut self, settings: &Self::Settings) {
self.0.update(settings);
}
fn change_line(&mut self, line: usize) {
CHANGES.with(|changes| changes.borrow_mut().push(line));
self.0.change_line(line);
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
self.0.highlight_line(line)
}
fn current_line(&self) -> usize {
self.0.current_line()
}
}
fn view(
editor: &text_editor::Content<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(source, crate::highlight::format)
.into()
}
#[test]
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
let before = "// earlier\n// unmodified\n@a";
let after = "// earlier\n// unmodified\n@none";
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
let mut tree = {
let mut widget = view(&editor, Arc::from(before));
let mut tree = Tree::new(widget.as_widget());
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
tree
};
CHANGES.with(|changes| changes.borrow_mut().clear());
assert!(replace(&mut editor, before, after));
{
let mut widget = view(&editor, Arc::from(after));
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
}
// Font/metrics/bounds are already stable. This notification must come
// from the helper's empty edit, not a fresh renderer initialization.
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
assert_eq!(editor.text(), after);
}
}
@@ -0,0 +1,448 @@
//! Combined App-boundary oracles: the workers are completed independently and
//! no returned Task is polled. Rendering measurements use the real reveal path.
use super::*;
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
use iced::{Size, Vector};
fn model_id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
}
fn navigation_fixture() -> App {
let mut source = bundle();
let entry = source.files.get_mut("project.arcadia").unwrap();
*entry = entry.replace(
"include \"uses.arcadia\";",
"include \"uses.arcadia\"; include \"views.arcadia\";",
);
source.files.insert(
"views.arcadia".into(),
concat!(
"// Keep @pump and the exact distant placement.\r\n",
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
).into(),
);
let mut app = fixture();
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
assert_eq!(app.document.bundle(), &source);
assert!(
!validate(app.project())
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
seek_pump(&mut app);
app.canvas_size = Some(Size::new(1200., 700.));
app.zoom = 0.8;
app.pan = Vector::new(31., 47.);
app
}
fn seek_pump(app: &mut App) {
let source = app.source_workspace.text();
let offset = source.rfind("pump;").unwrap();
app.source_editor
.move_to(cursor(source, offset, offset + 4));
}
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
let snapshot = Arc::new(
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
);
let results = snapshot
.query(query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action: request.action,
})
.unwrap();
nav::Completion {
request,
result: Ok((snapshot, Arc::new(results))),
}
}
fn diagram_completion(app: &App) -> usage::Completion {
let panel = app.diagram_usages.as_ref().unwrap();
let process = app.behavior.topology.usage_snapshot().into_query_state();
usage::Completion {
token: panel.token,
context: panel.context,
result: Ok(usage::collect(
app.project(),
panel.context.subject,
&process,
&behavior::scenario::State::default(),
)),
}
}
fn finish_lookup(app: &mut App) -> (Id, Usage) {
let completion = diagram_completion(app);
send(app, Message::DiagramUsagesReady(completion));
let panel = app.diagram_usages.as_ref().unwrap();
assert!(!panel.loading);
assert!(app.diagram_lookup_in_flight.is_none());
let found = panel
.usages
.iter()
.find(|u| {
u.surface == Surface::Saved(model_id(8))
&& u.target == UsageTarget::Placement(model_id(9))
})
.expect("the exact placed pump is represented")
.clone();
assert_eq!(found.subject, model_id(2));
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
(panel.token, found)
}
fn navigate(app: &mut App) -> Usage {
let message = app.diagram_usage_message(model_id(2));
send(app, message);
let (token, found) = finish_lookup(app);
send(
app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(model_id(8)));
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_usages.is_none());
assert!(app.pending_diagram_reveal.is_some());
assert!(app.diagram_focus(found.surface).is_some());
found
}
#[derive(Debug, PartialEq)]
struct Navigation {
page: Page,
selected: Id,
placement: Option<Id>,
diagram: Option<Id>,
zoom: f32,
pan: Vector,
pending: Option<(usage::Context, Usage, u64)>,
focus: Option<(usage::Context, Usage, Id)>,
canvas_epoch: u64,
camera_epoch: u64,
source_interaction: Id,
source_reveal: Option<Id>,
}
fn navigation(app: &App) -> Navigation {
Navigation {
page: app.page,
selected: app.selected,
placement: app.selected_node,
diagram: app.active_diagram,
zoom: app.zoom,
pan: app.pan,
pending: app.pending_diagram_reveal.clone(),
focus: app
.diagram_navigation_focus
.as_ref()
.map(|f| (f.context, f.usage.clone(), f.selected)),
canvas_epoch: app.canvas_epoch,
camera_epoch: app.camera_epoch,
source_interaction: app.source_symbols.interaction,
source_reveal: app.source_reveal_ticket(),
}
}
fn first_measurement(app: &mut App, found: &Usage) {
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
let (context, epoch) = (*context, *epoch);
let actual = Size::new(500., 300.);
send(app, Message::DiagramRevealViewport(context, epoch, actual));
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.canvas_size, Some(actual));
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_focus(found.surface).is_some());
}
#[test]
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
let mut app = navigation_fixture();
let original = evidence(&app);
let snapshot = app.document.snapshot();
let queued = app.diagram_usage_message(model_id(2));
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let form = app.source_rename.form.as_ref().unwrap();
let approved = (
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id,
);
send(&mut app, queued);
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, source);
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, definition);
assert!(app.diagram_usages.is_none());
assert!(app.diagram_lookup_in_flight.is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_navigation_available());
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!(
(
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id
),
approved
);
assert_eq!(form.replacement, "send_water");
assert_eq!(evidence(&app), original);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
send(&mut app, Message::Escape);
send(&mut app, confirm);
assert!(!app.source_rename.open());
assert_eq!(evidence(&app), original);
}
#[test]
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
for finish in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let queued_rename = app.source_rename_message();
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
if finish {
finish_lookup(&mut app);
}
let panel = app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let loading = panel.loading;
assert!(app.rename_admission().is_err());
assert!(!app.source_navigation_available());
send(&mut app, queued_rename);
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
let panel = app.diagram_usages.as_ref().unwrap();
assert_eq!((panel.token, panel.loading), (token, loading));
assert_eq!(evidence(&app), before);
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
let rename = app.source_rename_message();
send(&mut app, rename);
assert!(
app.source_rename.open(),
"the dismissed chooser no longer owns input"
);
}
}
#[test]
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
for preview_pending in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let completion = if preview_pending {
begin(&mut app);
input(&mut app, "send_water");
let job = start_preview(&mut app);
preview_completion(&app, job)
} else {
let rename = app.source_rename_message();
send(&mut app, rename);
target_completion(&app, current_job(&app))
};
let occupied = completion.job.id;
event(&mut app, Event::Close(completion.job.invocation));
assert_eq!(app.source_rename.in_flight, Some(occupied));
let found = navigate(&mut app);
let location = navigation(&app);
assert_eq!(evidence(&app), before);
let mut forged = completion.clone();
forged.job.id = Id::new_v4();
send(&mut app, Message::SourceRenameReady(forged));
assert_eq!(app.source_rename.in_flight, Some(occupied));
assert_eq!(navigation(&app), location);
send(&mut app, Message::SourceRenameReady(completion.clone()));
assert!(app.source_rename.in_flight.is_none());
assert!(!app.source_rename.open());
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
send(&mut app, Message::SourceRenameReady(completion));
assert_eq!(
navigation(&app),
location,
"duplicate completion is passive too"
);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), before);
}
}
#[test]
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
{
let mut app = navigation_fixture();
let before = evidence(&app);
let snapshot = app.document.snapshot();
let queued_begin = app.source_rename_message();
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
event(&mut app, Event::Close(invocation));
let found = navigate(&mut app);
let location = navigation(&app);
for stale in [
queued_begin,
Message::SourceRename(Event::Close(invocation)),
confirm,
] {
send(&mut app, stale);
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
first_measurement(&mut app, &found);
}
#[test]
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
let mut app = navigation_fixture();
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, source);
let request = app.source_symbols.request.clone().unwrap();
let source_ready = source_completion(&app, request.clone());
let rename = app.source_rename_message();
send(&mut app, rename);
let rename_ready = target_completion(&app, current_job(&app));
let rename_job = rename_ready.job.id;
event(&mut app, Event::Close(rename_ready.job.invocation));
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
let old_diagram = diagram_completion(&app);
let diagram_job = old_diagram.token;
let old_document = app.canvas_document;
let project = app.project().clone();
app.reset(project, None);
assert_ne!(app.canvas_document, old_document);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_rename.in_flight, Some(rename_job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
assert!(!app.source_rename.open());
assert!(app.diagram_usages.is_none());
let before = evidence(&app);
let snapshot = app.document.snapshot();
let new_open = app.diagram_usage_message(model_id(2));
send(&mut app, new_open);
let new_token = app.diagram_usages.as_ref().unwrap().token;
assert_ne!(new_token, diagram_job);
assert_eq!(
app.diagram_lookup_in_flight,
Some(diagram_job),
"replacement waits for old worker"
);
send(&mut app, Message::SourceRenameReady(rename_ready));
assert!(app.source_rename.in_flight.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
send(&mut app, Message::SourceSymbolsReady(source_ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open);
send(&mut app, Message::DiagramUsagesReady(old_diagram));
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
assert!(app.diagram_usages.as_ref().unwrap().loading);
finish_lookup(&mut app);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.source_rename.open());
}
#[test]
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
let mut app = navigation_fixture();
let baseline = app.document.snapshot();
let project = app.project().id;
app.document
.execute(Command::Rename(project, "Previously accepted name".into()))
.unwrap();
app.sync_source();
app.refresh_draft();
seek_pump(&mut app);
let before = app.document.snapshot();
let mut expected_project = app.project().clone();
expected_project
.elements
.get_mut(&model_id(2))
.unwrap()
.alias = "send_water".into();
let mut expected_source = app.document.bundle().clone();
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
*uses = uses
.replace("source @pump;", "source @send_water;")
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
let views = expected_source.files.get_mut("views.arcadia").unwrap();
*views = views.replace("element @pump;", "element @send_water;");
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
send(&mut app, confirm);
assert_eq!(app.project(), &expected_project);
assert_eq!(app.document.bundle(), &expected_source);
let renamed = app.document.snapshot();
let accepted = evidence(&app);
let queued_undo_rename = app
.source_rename_undo_message()
.expect("fresh rename offers its guarded undo");
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, message);
let request = app
.source_symbols
.request
.clone()
.expect("renamed exact caret remains queryable");
let ready = source_completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(ready));
let (token, found) = finish_lookup(&mut app);
send(
&mut app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
let destination = navigation(&app);
send(&mut app, queued_undo_rename);
assert_eq!(navigation(&app), destination);
assert_eq!(evidence(&app), accepted);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), accepted);
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
for (message, expected) in [
(Message::Undo, before.clone()),
(Message::Redo, renamed.clone()),
(Message::Undo, before.clone()),
(Message::Undo, baseline.clone()),
(Message::Redo, before),
(Message::Redo, renamed),
] {
send(&mut app, message);
assert!(
Arc::ptr_eq(&expected, &app.document.snapshot()),
"exact history entry restored"
);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.source_workspace.bundle(), expected.bundle);
assert!(!app.source_dirty());
}
}
@@ -0,0 +1,449 @@
//! Real paragraph advances and clipping through the actual preview dialog.
//! Native screenshots additionally check raster ink and the platform renderer.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
use std::collections::{BTreeMap, BTreeSet};
struct Glyph {
text: String,
paragraph_y: u32,
line: usize,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
Self {
wanted,
transforms: vec![Transformation::IDENTITY],
clips: vec![viewport],
glyphs: vec![],
quads: vec![],
}
}
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for GlyphRecorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.transforms = vec![Transformation::IDENTITY];
self.clips = vec![bounds];
self.glyphs.clear();
self.quads.clear();
}
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let text = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
if !self.wanted.contains(&text) {
return;
}
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if run.text[glyph.start..glyph.end]
.chars()
.all(char::is_whitespace)
{
continue;
}
let bounds = Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform();
self.glyphs.push(Glyph {
text: text.clone(),
paragraph_y: position.y.to_bits(),
line: run.line_i,
bytes: glyph.start..glyph.end,
bounds,
clip,
});
}
}
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
position: Point,
color: Color,
clip: Rectangle,
) {
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
}
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
}
}
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
}
impl widget::Operation for ScrollGeometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
}
}
}
fn normalized(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
text.split('\n')
.enumerate()
.flat_map(|(line, text)| {
text.char_indices()
.filter(|(_, ch)| !ch.is_whitespace())
.map(move |(byte, _)| (line, byte))
})
.collect()
}
fn declarations(long: bool) -> App {
let name = if long {
format!("Assess {} request", "界🚀".repeat(90))
} else {
"Assess 救援 request".into()
};
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
]) };
let mut app = fixture();
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "救援/assess.arcadia".into();
app.selected = app.project().id;
app.refresh_draft();
app.show_source_file(false);
let start = app
.source_workspace
.text()
.find("oa_activity assess")
.unwrap()
+ "oa_activity ".len();
app.source_editor.move_to(cursor(
app.source_workspace.text(),
start,
start + "assess".len(),
));
begin(&mut app);
let replacement = if long {
"a".repeat(255)
} else {
"assess_damage".into()
};
input(&mut app, &replacement);
let _ = preview(&mut app);
app
}
fn assert_page_glyphs_visible(app: &App, host: Size) {
let form = app.source_rename.form.as_ref().unwrap();
let ready = form.approval.as_ref().unwrap();
let file = &ready.preview.files()[form.file];
let mut expected = BTreeMap::<String, usize>::new();
for line in file.hunks()[form.hunk]
.lines()
.iter()
.skip(form.line)
.take(LINES_PER_PAGE)
{
for source in [
line.before_text(&ready.prepared.before().bundle.files[file.path()])
.unwrap(),
line.after_text(&ready.prepared.after().bundle.files[file.path()])
.unwrap(),
] {
if let Some(fragment) = preview::text_segment(source, form.segment) {
let text = normalized(fragment);
if !required_chars(&text).is_empty() {
*expected.entry(text).or_default() += 1;
}
}
}
}
assert!(!expected.is_empty());
let viewport = Rectangle::new(Point::ORIGIN, host);
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
let editor = text_editor::Content::<GlyphRecorder>::new();
let mut view = dialog_content(app, &editor);
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
let content = geometry.content.unwrap();
assert!(bounds.height >= 40.);
let maximum = (content.height - bounds.height).max(0.);
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
let mut reserved_bar_gap = true;
for step in 0..=steps {
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
),
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
assert!(
(geometry.offset.y - offset).abs() <= 0.5,
"actual scroll operation reaches the requested source offset"
);
renderer::Renderer::reset(&mut renderer, viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
// The vertical scrollbar's actual painted thumb fixes its excluded x
// region; the outer scroll clip alone cannot detect overpainting.
let scrollbar_left = renderer
.quads
.iter()
.filter(|quad| {
quad.width > 0.
&& quad.width <= 16.
&& quad.height > 0.
&& quad.x >= bounds.x + bounds.width - 18.
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
&& quad.y >= bounds.y - 0.5
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
})
.map(|quad| quad.x)
.min_by(f32::total_cmp);
if maximum > 0. {
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
reserved_bar_gap &= content.x + content.width <= left - 7.5;
}
for glyph in &renderer.glyphs {
let right = glyph.bounds.x + glyph.bounds.width;
if let Some(left) = scrollbar_left {
assert!(
right <= left - 0.5,
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
glyph.text,
glyph.bytes,
glyph.bounds
);
}
assert!(
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
"source glyph escaped reserved width: {:?} {:?}",
glyph.text,
glyph.bounds
);
assert!(
glyph.bounds.x >= glyph.clip.x - 0.5
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
"source glyph horizontally clipped: {:?} {:?} {:?}",
glyph.text,
glyph.bounds,
glyph.clip
);
if glyph.bounds.y >= glyph.clip.y - 0.5
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
{
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
let chars = logical_line
.char_indices()
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
.map(|(byte, _)| (glyph.line, byte));
visible
.entry((glyph.text.clone(), glyph.paragraph_y))
.or_default()
.extend(chars);
}
}
}
assert!(
reserved_bar_gap,
"source content must reserve actual scrollbar width plus gap"
);
assert!(
bounds.width >= host.width - 30.,
"preview must use available dialog width: {bounds:?} / {host:?}"
);
for (text, count) in expected {
let required = required_chars(&text);
let instances: Vec<_> = visible
.iter()
.filter(|((value, _), _)| value == &text)
.collect();
assert_eq!(
instances.len(),
count,
"all before/after occurrences must be seen: {text:?}"
);
for (_, seen) in instances {
assert_eq!(
seen, &required,
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
);
}
}
}
#[test]
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
for long in [false, true] {
let mut app = declarations(long);
let ready = app
.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.clone();
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
let before = evidence(&app);
for (width, height) in [(1100., 700.), (580., 700.)] {
app.window_size = Size::new(width, height);
let host = Size::new((width - 84.).min(708.), height - 120.);
let total = segment_total(app.source_rename.form.as_ref().unwrap());
if long {
assert!(
total >= 3,
"include unbroken ASCII and wide Unicode across segments"
);
}
for segment in 0..total {
assert_page_glyphs_visible(&app, host);
if segment + 1 < total {
event(&mut app, Event::Segment(invocation, ready.id, true));
}
}
for _ in 1..total {
event(&mut app, Event::Segment(invocation, ready.id, false));
}
}
assert_eq!(
evidence(&app),
before,
"preview geometry/paging cannot alter transaction inputs"
);
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
ready.id
);
}
}
@@ -0,0 +1,385 @@
//! Execute the controller's scroll operations on a retained, scrolled dialog.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::{Rectangle, Size, Vector};
const HOST: Size = Size::new(720., 660.);
fn paged_fixture() -> App {
let mut source = bundle();
let name = "Long engineering name 雨 ".repeat(18);
source.files.get_mut("defs.arcadia").unwrap().replace_range(
..,
&format!(
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
),
);
let mut uses = String::new();
for index in 0..16 {
if index == 12 {
uses.push_str(&"// unchanged gap\r\n".repeat(10));
}
uses.push_str(&format!(
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
));
}
source.files.insert("uses.arcadia".into(), uses);
let mut app = fixture();
app.window_size = HOST;
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(cursor(app.source_workspace.text(), start, start + 4));
begin(&mut app);
input(&mut app, "pump_long");
let _ = preview(&mut app);
app
}
fn scroll_id(app: &App) -> widget::Id {
let form = app.source_rename.form.as_ref().unwrap();
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
}
struct Geometry {
id: widget::Id,
offset: Option<Vector>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
struct Mounted {
editor: text_editor::Content<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::new();
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
Self { editor, tree }
}
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut view = dialog_content(app, &self.editor);
// Preserve the actual scroll state across the new page's view diff.
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut()
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
}
fn offset(&mut self, app: &App) -> f32 {
let mut geometry = Geometry {
id: scroll_id(app),
offset: None,
};
self.operate(app, &mut geometry);
geometry.offset.expect("mounted preview scrollable").y
}
fn scroll_down(&mut self, app: &App) -> f32 {
self.operate(
app,
&mut widget::operation::scrollable::scroll_to(
scroll_id(app),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(10_000.),
},
),
);
let offset = self.offset(app);
assert!(offset > 10., "fixture must really be scrolled");
offset
}
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
let mut count = 0;
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("preview paging must only schedule a widget operation");
};
self.operate(app, operation.as_mut());
count += 1;
}
}
count
}
}
#[tokio::test]
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let old_id = scroll_id(&app);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_ne!(old_id, scroll_id(&app));
// Negative control: even after diff/layout with the new id and new file,
// Iced retains the previous offset until the returned operation executes.
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
for navigation in [
Event::Line(invocation, approval, true),
Event::Segment(invocation, approval, true),
Event::Hunk(invocation, approval, true),
Event::File(invocation, approval, false),
Event::Search(invocation, "uses".into()),
Event::Search(invocation, "no-path-match".into()),
Event::Search(invocation, String::new()),
] {
mounted.scroll_down(&app);
let task = app.update(Message::SourceRename(navigation));
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
// Passive redraw and unchanged filter input preserve deliberate scrolling.
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Search(
invocation,
String::new(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Input(
invocation,
"pump_long".into(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, false,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"clamped first-page request is a no-op"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let old_approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let old_page_task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
let next_page_task = app.update(Message::SourceRename(Event::Line(
invocation,
old_approval,
true,
)));
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
let old_approval_task = app.update(Message::SourceRename(Event::Search(
invocation,
"defs".into(),
)));
input(&mut app, "pump_new");
let _ = preview(&mut app); // Real new approval in the same invocation/page.
assert_ne!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
old_approval
);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old approval target is absent"
);
let task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"stale envelope is rejected before scheduling"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let first_visit = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let next_page = app.update(Message::SourceRename(Event::Line(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next_page).await, 1);
let return_visit = app.update(Message::SourceRename(Event::Line(
invocation, approval, false,
)));
assert_eq!(mounted.execute(&app, return_visit).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, first_visit).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old visit to the identical page is stale"
);
let old_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"uses".into(),
)));
let new_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"us".into(),
)));
assert_eq!(mounted.execute(&app, new_filter).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_filter).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old filter on the same page is stale"
);
input(&mut app, "pump_new");
let job = start_preview(&mut app);
let completion = preview_completion(&app, job);
// No intermediate frame mounts the temporary no-approval view. The old
// Scrollable survives even though the new approval has a different id.
let ready = app.update(Message::SourceRenameReady(completion));
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, ready).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
{
// Exercise both an actually mounted Details view and a fast round trip
// between frames, where Iced retains the same preview Scrollable state.
for mount_details in [true, false] {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let held_reset = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let old_preview = scroll_id(&app);
let form = app.source_rename.form.as_ref().unwrap();
let page = (form.file, form.hunk, form.line, form.segment);
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, show_details).await, 0);
let details_visit = scroll_id(&app);
assert_ne!(details_visit, old_preview);
if mount_details {
let mut hidden = Geometry {
id: old_preview.clone(),
offset: None,
};
mounted.operate(&app, &mut hidden);
assert!(
hidden.offset.is_none(),
"Details actually replaced the preview"
);
}
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
assert_eq!(
scroll_id(&app),
details_visit,
"unchanged visibility keeps its visit"
);
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(
mounted.execute(&app, show_preview).await,
0,
"returning does not force a scroll reset"
);
let returned_visit = scroll_id(&app);
assert_ne!(returned_visit, old_preview);
assert_ne!(returned_visit, details_visit);
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
assert_eq!(form.approval.as_ref().unwrap().id, approval);
let offset = mounted.scroll_down(&app);
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
assert_eq!(scroll_id(&app), returned_visit);
assert_eq!(
mounted.offset(&app),
offset,
"repeated Preview preserves deliberate scroll"
);
assert_eq!(mounted.execute(&app, held_reset).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"the previous visit cannot reset the returned preview"
);
assert_eq!(evidence(&app), before);
}
}
File diff suppressed because it is too large Load Diff

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