Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a578ac98a0 | ||
|
|
eeb1069cfc | ||
|
|
754a9b4b7c | ||
|
|
0fc0826435 | ||
|
|
10d7f3340d | ||
|
|
75bc285386 | ||
|
|
ded72c2339 | ||
|
|
1b1c3e3abb | ||
|
|
90fae14818 | ||
|
|
1f8283236f | ||
|
|
f1d79df497 | ||
|
|
bed5b0e832 | ||
|
|
a2a28f8d23 | ||
|
|
472fe7a433 | ||
|
|
6de3b53103 | ||
|
|
b2c970b467 | ||
|
|
021bdc5a92 | ||
|
|
6216cff4cf | ||
|
|
9ff6e7cf7a | ||
|
|
2c515cf3bd | ||
|
|
e8a18a7cf9 | ||
|
|
02b9d5ce4c | ||
|
|
112e0d910b | ||
|
|
bff8014c1f | ||
|
|
df3f4072dd | ||
|
|
1513aa2794 | ||
|
|
3174e24eea | ||
|
|
847b1caa7e | ||
|
|
cdebb49d89 | ||
|
|
b96ff3935a | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 |
+10
-47
@@ -22,65 +22,28 @@ harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
|
||||
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
|
||||
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
|
||||
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
|
||||
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
|
||||
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
|
||||
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
|
||||
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
|
||||
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
|
||||
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
|
||||
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
|
||||
# Preserve the independently reviewed codec handoff bytes.
|
||||
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
|
||||
|
||||
Generated
+64
-1
@@ -65,6 +65,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -2107,6 +2116,25 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2153,9 +2181,13 @@ name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"stacker",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
@@ -2176,7 +2208,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2487,6 +2518,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2710,6 +2750,16 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3430,6 +3480,19 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -7,11 +7,15 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["unbounded_depth"] }
|
||||
stacker = "0.1.25"
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Schema 8 engineering definitions and static admission. No expression evaluation.
|
||||
mod admission;
|
||||
mod expression_lifecycle;
|
||||
pub(crate) mod expression_serde;
|
||||
pub mod references;
|
||||
mod resolve;
|
||||
mod typecheck;
|
||||
mod types;
|
||||
|
||||
pub(crate) use admission::admit_structure_checked;
|
||||
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
|
||||
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
|
||||
pub use types::*;
|
||||
|
||||
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
|
||||
pub const MAX_EXPRESSION_DEPTH: usize = 128;
|
||||
pub const MAX_CLOSURE_NODES: usize = 65_536;
|
||||
pub const MAX_LIST_LENGTH: usize = 1_024;
|
||||
pub const MAX_CALL_DEPTH: usize = 32;
|
||||
|
||||
/// Additional persisted records for existing transport ParseLimits. Definitions
|
||||
/// themselves count as graph objects there; this counts parameters, overrides,
|
||||
/// and every stored expression node, with saturating arithmetic and no recursion.
|
||||
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
|
||||
let mut count = 0usize;
|
||||
fn binding(value: &Binding, count: &mut usize) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
references::walk(expression, &mut |_| {
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values() {
|
||||
if let DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
count = count.saturating_add(calculation.parameters.len());
|
||||
binding(&calculation.body, &mut count);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values() {
|
||||
match element.shared() {
|
||||
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
|
||||
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
count = count.saturating_add(overrides.len());
|
||||
for value in overrides.values() {
|
||||
binding(value, &mut count);
|
||||
}
|
||||
}
|
||||
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
|
||||
binding(predicate, &mut count)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
//! Ordinary expression ownership never consumes stack proportional to tree depth.
|
||||
//! This also covers a partially decoded expression and rejected programmatic input.
|
||||
use super::Expr;
|
||||
use std::{fmt, mem};
|
||||
|
||||
impl Expr {
|
||||
fn clone_shell(&self) -> Self {
|
||||
let leaf = || Box::new(Self::Boolean(false));
|
||||
match self {
|
||||
Self::Boolean(value) => Self::Boolean(*value),
|
||||
Self::Number(value) => Self::Number(value.clone()),
|
||||
Self::Quantity { magnitude, unit } => Self::Quantity {
|
||||
magnitude: magnitude.clone(),
|
||||
unit: *unit,
|
||||
},
|
||||
Self::Property(id) => Self::Property(*id),
|
||||
Self::Parameter(id) => Self::Parameter(*id),
|
||||
Self::List(values) => {
|
||||
Self::List((0..values.len()).map(|_| Self::Boolean(false)).collect())
|
||||
}
|
||||
Self::Unary { op, .. } => Self::Unary {
|
||||
op: *op,
|
||||
operand: leaf(),
|
||||
},
|
||||
Self::Binary { op, .. } => Self::Binary {
|
||||
op: *op,
|
||||
left: leaf(),
|
||||
right: leaf(),
|
||||
},
|
||||
Self::If { .. } => Self::If {
|
||||
condition: leaf(),
|
||||
when_true: leaf(),
|
||||
when_false: leaf(),
|
||||
},
|
||||
Self::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => Self::Call {
|
||||
definition: *definition,
|
||||
arguments: arguments
|
||||
.keys()
|
||||
.map(|id| (*id, Self::Boolean(false)))
|
||||
.collect(),
|
||||
},
|
||||
Self::Sum { .. } => Self::Sum {
|
||||
values: leaf(),
|
||||
initial: leaf(),
|
||||
},
|
||||
Self::Power { exponent, .. } => Self::Power {
|
||||
base: leaf(),
|
||||
exponent: *exponent,
|
||||
},
|
||||
Self::AsKind { kind, .. } => Self::AsKind {
|
||||
kind: *kind,
|
||||
value: leaf(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn detach_children(&mut self, pending: &mut Vec<Self>) {
|
||||
let mut take = |child: &mut Box<Self>| {
|
||||
pending.push(mem::replace(child.as_mut(), Self::Boolean(false)));
|
||||
};
|
||||
match self {
|
||||
Self::Unary { operand, .. } => take(operand),
|
||||
Self::Binary { left, right, .. } => {
|
||||
take(left);
|
||||
take(right);
|
||||
}
|
||||
Self::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
take(condition);
|
||||
take(when_true);
|
||||
take(when_false);
|
||||
}
|
||||
Self::Sum { values, initial } => {
|
||||
take(values);
|
||||
take(initial);
|
||||
}
|
||||
Self::Power { base, .. } => take(base),
|
||||
Self::AsKind { value, .. } => take(value),
|
||||
Self::List(values) => pending.append(values),
|
||||
Self::Call { arguments, .. } => {
|
||||
pending.extend(mem::take(arguments).into_values());
|
||||
}
|
||||
Self::Boolean(_)
|
||||
| Self::Number(_)
|
||||
| Self::Quantity { .. }
|
||||
| Self::Property(_)
|
||||
| Self::Parameter(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Expr {
|
||||
fn drop(&mut self) {
|
||||
let mut pending = Vec::new();
|
||||
self.detach_children(&mut pending);
|
||||
while let Some(mut node) = pending.pop() {
|
||||
node.detach_children(&mut pending);
|
||||
// node now owns only leaves/empty collections. Its ordinary drop
|
||||
// reenters this method at most once, independently of original depth.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Expr {
|
||||
fn clone(&self) -> Self {
|
||||
let mut result = Self::Boolean(false);
|
||||
let mut pending = vec![(self, &mut result)];
|
||||
while let Some((source, destination)) = pending.pop() {
|
||||
*destination = source.clone_shell();
|
||||
match (source, destination) {
|
||||
(Self::List(a), Self::List(b)) => pending.extend(a.iter().zip(b.iter_mut())),
|
||||
(Self::Unary { operand: a, .. }, Self::Unary { operand: b, .. })
|
||||
| (Self::Power { base: a, .. }, Self::Power { base: b, .. })
|
||||
| (Self::AsKind { value: a, .. }, Self::AsKind { value: b, .. }) => {
|
||||
pending.push((a, b));
|
||||
}
|
||||
(
|
||||
Self::Binary {
|
||||
left: a, right: b, ..
|
||||
},
|
||||
Self::Binary {
|
||||
left: c, right: d, ..
|
||||
},
|
||||
)
|
||||
| (
|
||||
Self::Sum {
|
||||
values: a,
|
||||
initial: b,
|
||||
},
|
||||
Self::Sum {
|
||||
values: c,
|
||||
initial: d,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, c));
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::If {
|
||||
condition: a,
|
||||
when_true: b,
|
||||
when_false: c,
|
||||
},
|
||||
Self::If {
|
||||
condition: d,
|
||||
when_true: e,
|
||||
when_false: f,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, d));
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(Self::Call { arguments: a, .. }, Self::Call { arguments: b, .. }) => {
|
||||
pending.extend(a.values().zip(b.values_mut()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Expr {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let mut pending = vec![(self, other)];
|
||||
while let Some((a, b)) = pending.pop() {
|
||||
match (a, b) {
|
||||
(Self::Boolean(a), Self::Boolean(b)) if a == b => {}
|
||||
(Self::Number(a), Self::Number(b)) if a == b => {}
|
||||
(
|
||||
Self::Quantity {
|
||||
magnitude: a,
|
||||
unit: b,
|
||||
},
|
||||
Self::Quantity {
|
||||
magnitude: c,
|
||||
unit: d,
|
||||
},
|
||||
) if a == c && b == d => {}
|
||||
(Self::Property(a), Self::Property(b)) if a == b => {}
|
||||
(Self::Parameter(a), Self::Parameter(b)) if a == b => {}
|
||||
(Self::List(a), Self::List(b)) if a.len() == b.len() => {
|
||||
pending.extend(a.iter().zip(b.iter()));
|
||||
}
|
||||
(Self::Unary { op: a, operand: b }, Self::Unary { op: c, operand: d })
|
||||
if a == c =>
|
||||
{
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::Binary {
|
||||
op: a,
|
||||
left: b,
|
||||
right: c,
|
||||
},
|
||||
Self::Binary {
|
||||
op: d,
|
||||
left: e,
|
||||
right: f,
|
||||
},
|
||||
) if a == d => {
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(
|
||||
Self::If {
|
||||
condition: a,
|
||||
when_true: b,
|
||||
when_false: c,
|
||||
},
|
||||
Self::If {
|
||||
condition: d,
|
||||
when_true: e,
|
||||
when_false: f,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, d));
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(
|
||||
Self::Call {
|
||||
definition: a,
|
||||
arguments: b,
|
||||
},
|
||||
Self::Call {
|
||||
definition: c,
|
||||
arguments: d,
|
||||
},
|
||||
) if a == c && b.len() == d.len() => {
|
||||
for ((key_a, value_a), (key_b, value_b)) in b.iter().zip(d.iter()) {
|
||||
if key_a != key_b {
|
||||
return false;
|
||||
}
|
||||
pending.push((value_a, value_b));
|
||||
}
|
||||
}
|
||||
(
|
||||
Self::Sum {
|
||||
values: a,
|
||||
initial: b,
|
||||
},
|
||||
Self::Sum {
|
||||
values: c,
|
||||
initial: d,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, c));
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::Power {
|
||||
base: a,
|
||||
exponent: b,
|
||||
},
|
||||
Self::Power {
|
||||
base: c,
|
||||
exponent: d,
|
||||
},
|
||||
) if b == d => {
|
||||
pending.push((a, c));
|
||||
}
|
||||
(Self::AsKind { kind: a, value: b }, Self::AsKind { kind: c, value: d })
|
||||
if a == c =>
|
||||
{
|
||||
pending.push((b, d));
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Eq for Expr {}
|
||||
|
||||
impl fmt::Debug for Expr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
enum Task<'a> {
|
||||
Node(&'a Expr),
|
||||
Text(&'static str),
|
||||
Scalar(&'a dyn fmt::Debug),
|
||||
}
|
||||
use Task::*;
|
||||
let mut pending = vec![Node(self)];
|
||||
while let Some(task) = pending.pop() {
|
||||
match task {
|
||||
Text(text) => f.write_str(text)?,
|
||||
Scalar(value) => write!(f, "{value:?}")?,
|
||||
Node(node) => match node {
|
||||
Self::Boolean(value) => write!(f, "Boolean({value:?})")?,
|
||||
Self::Number(value) => write!(f, "Number({value:?})")?,
|
||||
Self::Quantity { magnitude, unit } => {
|
||||
write!(f, "Quantity {{ magnitude: {magnitude:?}, unit: {unit:?} }}")?;
|
||||
}
|
||||
Self::Property(id) => write!(f, "Property({id:?})")?,
|
||||
Self::Parameter(id) => write!(f, "Parameter({id:?})")?,
|
||||
Self::List(values) => {
|
||||
f.write_str("List([")?;
|
||||
pending.push(Text("])"));
|
||||
for (index, value) in values.iter().enumerate().rev() {
|
||||
pending.push(Node(value));
|
||||
if index > 0 {
|
||||
pending.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Unary { op, operand } => {
|
||||
write!(f, "Unary {{ op: {op:?}, operand: ")?;
|
||||
pending.extend([Text(" }"), Node(operand)]);
|
||||
}
|
||||
Self::Binary { op, left, right } => {
|
||||
write!(f, "Binary {{ op: {op:?}, left: ")?;
|
||||
pending.extend([Text(" }"), Node(right), Text(", right: "), Node(left)]);
|
||||
}
|
||||
Self::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
f.write_str("If { condition: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Node(when_false),
|
||||
Text(", when_false: "),
|
||||
Node(when_true),
|
||||
Text(", when_true: "),
|
||||
Node(condition),
|
||||
]);
|
||||
}
|
||||
Self::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
write!(f, "Call {{ definition: {definition:?}, arguments: {{")?;
|
||||
pending.push(Text("} }"));
|
||||
for (index, (id, value)) in arguments.iter().enumerate().rev() {
|
||||
pending.extend([Node(value), Text(": "), Scalar(id)]);
|
||||
if index > 0 {
|
||||
pending.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Sum { values, initial } => {
|
||||
f.write_str("Sum { values: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Node(initial),
|
||||
Text(", initial: "),
|
||||
Node(values),
|
||||
]);
|
||||
}
|
||||
Self::Power { base, exponent } => {
|
||||
f.write_str("Power { base: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Scalar(exponent),
|
||||
Text(", exponent: "),
|
||||
Node(base),
|
||||
]);
|
||||
}
|
||||
Self::AsKind { kind, value } => {
|
||||
write!(f, "AsKind {{ kind: {kind:?}, value: ")?;
|
||||
pending.extend([Text(" }"), Node(value)]);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! The existing externally tagged Serde schema, with admission at each Expr edge.
|
||||
//! The private remote derive is the sole wire grammar: it still rejects unknown
|
||||
//! fields/variants, duplicate fields and malformed UUIDs/Exact values.
|
||||
use super::{
|
||||
BinaryOp, DefinitionRef, Expr, MAX_CLOSURE_NODES, MAX_EXPRESSION_DEPTH, MAX_LIST_LENGTH,
|
||||
ParameterId, UnaryOp,
|
||||
};
|
||||
use crate::{Id, analysis_numeric::Exact};
|
||||
use serde::{
|
||||
Deserialize, Serialize,
|
||||
de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
|
||||
};
|
||||
use std::{cell::Cell, collections::BTreeMap, fmt};
|
||||
|
||||
pub(crate) const MAX_JSON_EXPRESSION_NODES: usize = 262_144;
|
||||
// These are requested segment capacities, not an allocator or RSS guarantee.
|
||||
const RED_ZONE: usize = 64 * 1024;
|
||||
const STACK_SEGMENT: usize = 512 * 1024;
|
||||
|
||||
pub(crate) fn with_stack<R>(operation: impl FnOnce() -> R) -> R {
|
||||
stacker::maybe_grow(RED_ZONE, STACK_SEGMENT, operation)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Meter {
|
||||
active: bool,
|
||||
depth: usize,
|
||||
nodes: usize,
|
||||
tree_nodes: usize,
|
||||
maximum: usize,
|
||||
}
|
||||
impl Meter {
|
||||
const DEFAULT: Self = Self {
|
||||
active: false,
|
||||
depth: 0,
|
||||
nodes: 0,
|
||||
tree_nodes: 0,
|
||||
maximum: MAX_JSON_EXPRESSION_NODES,
|
||||
};
|
||||
}
|
||||
thread_local! {
|
||||
static DECODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
|
||||
static ENCODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
|
||||
}
|
||||
type LocalMeter = &'static std::thread::LocalKey<Cell<Meter>>;
|
||||
|
||||
/// One codec operation owns the aggregate meter across all disjoint bindings.
|
||||
/// Scope restoration also runs on errors and unwinding; no mutable TLS borrow is
|
||||
/// held across Serde/user code or a stack switch.
|
||||
pub(crate) struct Scope {
|
||||
local: LocalMeter,
|
||||
previous: Meter,
|
||||
}
|
||||
impl Scope {
|
||||
pub(crate) fn decoding(maximum: usize) -> Self {
|
||||
Self::new(&DECODING, maximum)
|
||||
}
|
||||
pub(crate) fn encoding(maximum: usize) -> Self {
|
||||
Self::new(&ENCODING, maximum)
|
||||
}
|
||||
fn new(local: LocalMeter, maximum: usize) -> Self {
|
||||
let next = Meter {
|
||||
active: true,
|
||||
maximum: maximum.min(MAX_JSON_EXPRESSION_NODES),
|
||||
..Meter::DEFAULT
|
||||
};
|
||||
let previous = local.with(|meter| meter.replace(next));
|
||||
Self { local, previous }
|
||||
}
|
||||
}
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
self.local.with(|meter| meter.set(self.previous));
|
||||
}
|
||||
}
|
||||
|
||||
struct NodeGuard {
|
||||
local: LocalMeter,
|
||||
root: bool,
|
||||
}
|
||||
impl NodeGuard {
|
||||
fn enter(local: LocalMeter) -> Result<Self, &'static str> {
|
||||
local.with(|slot| {
|
||||
let mut meter = slot.get();
|
||||
let root = !meter.active;
|
||||
if root {
|
||||
meter = Meter {
|
||||
active: true,
|
||||
..Meter::DEFAULT
|
||||
};
|
||||
}
|
||||
if meter.depth == 0 {
|
||||
meter.tree_nodes = 0;
|
||||
}
|
||||
if meter.depth >= MAX_EXPRESSION_DEPTH {
|
||||
return Err("Expression logical depth exceeds 128.");
|
||||
}
|
||||
if meter.nodes >= meter.maximum {
|
||||
return Err("JSON expression allocation count exceeded.");
|
||||
}
|
||||
if meter.tree_nodes >= MAX_CLOSURE_NODES {
|
||||
return Err("Stored expression node count exceeds 65536.");
|
||||
}
|
||||
meter.depth += 1;
|
||||
meter.nodes += 1;
|
||||
meter.tree_nodes += 1;
|
||||
slot.set(meter);
|
||||
Ok(Self { local, root })
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Drop for NodeGuard {
|
||||
fn drop(&mut self) {
|
||||
self.local.with(|slot| {
|
||||
let mut meter = slot.get();
|
||||
meter.depth -= 1;
|
||||
if self.root {
|
||||
meter.active = false;
|
||||
}
|
||||
slot.set(meter);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Expr {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let _node = NodeGuard::enter(&DECODING).map_err(serde::de::Error::custom)?;
|
||||
with_stack(|| WireExpr::deserialize(deserializer))
|
||||
}
|
||||
}
|
||||
impl Serialize for Expr {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let _node = NodeGuard::enter(&ENCODING).map_err(serde::ser::Error::custom)?;
|
||||
match self {
|
||||
Self::List(values) if values.len() > MAX_LIST_LENGTH => {
|
||||
return Err(serde::ser::Error::custom(
|
||||
"Expression List exceeds 1024 items.",
|
||||
));
|
||||
}
|
||||
Self::Call { arguments, .. } if arguments.len() > MAX_CLOSURE_NODES => {
|
||||
return Err(serde::ser::Error::custom(
|
||||
"Call argument count exceeds 65536.",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
with_stack(|| WireExpr::serialize(self, serializer))
|
||||
}
|
||||
}
|
||||
|
||||
// A private remote derive avoids exposing an unguarded inherent deserialize on
|
||||
// public Expr. Children remain Expr, so every recursive edge enters the guard.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "Expr", deny_unknown_fields)]
|
||||
enum WireExpr {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: DefinitionRef,
|
||||
},
|
||||
Property(Id),
|
||||
Parameter(ParameterId),
|
||||
List(#[serde(deserialize_with = "bounded_list")] Vec<Expr>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expr>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expr>,
|
||||
when_true: Box<Expr>,
|
||||
when_false: Box<Expr>,
|
||||
},
|
||||
Call {
|
||||
definition: DefinitionRef,
|
||||
#[serde(deserialize_with = "bounded_arguments")]
|
||||
arguments: BTreeMap<ParameterId, Expr>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expr>,
|
||||
initial: Box<Expr>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expr>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: DefinitionRef,
|
||||
value: Box<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ListItem {
|
||||
allowed: bool,
|
||||
}
|
||||
impl<'de> DeserializeSeed<'de> for ListItem {
|
||||
type Value = Expr;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<Expr, D::Error> {
|
||||
if !self.allowed {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Expression List exceeds 1024 items.",
|
||||
));
|
||||
}
|
||||
Expr::deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
fn bounded_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Expr>, D::Error> {
|
||||
struct ListVisitor;
|
||||
impl<'de> Visitor<'de> for ListVisitor {
|
||||
type Value = Vec<Expr>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("at most 1024 expression items")
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = access.next_element_seed(ListItem {
|
||||
allowed: values.len() < MAX_LIST_LENGTH,
|
||||
})? {
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(ListVisitor)
|
||||
}
|
||||
fn bounded_arguments<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
|
||||
struct Arguments;
|
||||
impl<'de> Visitor<'de> for Arguments {
|
||||
type Value = BTreeMap<ParameterId, Expr>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("unique scoped parameter UUID keys")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = BTreeMap::new();
|
||||
while let Some(id) = access.next_key::<ParameterId>()? {
|
||||
if values.contains_key(&id) {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"Duplicate parameter ID {}",
|
||||
id.0
|
||||
)));
|
||||
}
|
||||
if values.len() >= MAX_CLOSURE_NODES {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Call argument count exceeds 65536.",
|
||||
));
|
||||
}
|
||||
// Count/duplicate checks precede construction of the next subtree.
|
||||
values.insert(id, access.next_value()?);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Arguments)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
|
||||
use super::*;
|
||||
use crate::{Element, Id, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| {
|
||||
if let Expr::Property(id) = node {
|
||||
found.insert(*id);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| match node {
|
||||
Expr::Quantity { unit, .. } => {
|
||||
found.insert(unit.local());
|
||||
}
|
||||
Expr::Call { definition, .. } => {
|
||||
found.insert(definition.local());
|
||||
}
|
||||
Expr::AsKind { kind, .. } => {
|
||||
found.insert(kind.local());
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter().rev()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(when_false);
|
||||
pending.push(when_true);
|
||||
pending.push(condition);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter_mut()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(left);
|
||||
pending.push(right);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(condition);
|
||||
pending.push(when_true);
|
||||
pending.push(when_false);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
|
||||
let mut result = BTreeSet::new();
|
||||
let mut binding = |value: &Binding| {
|
||||
if let Binding::Expression(expression) = value {
|
||||
result.extend(expression_properties(expression));
|
||||
}
|
||||
};
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
|
||||
Some(SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
for value in overrides.values() {
|
||||
binding(value);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
|
||||
_ => {}
|
||||
}
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { subject, .. }) => {
|
||||
result.insert(subject.id());
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(overrides.keys());
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(documented_constraint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
|
||||
let DefinitionRef::Local(id) = reference;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
if let ScalarType::Quantity(reference) = scalar {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
}
|
||||
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
walk_mut(expression, &mut |node| match node {
|
||||
Expr::Property(id) if *id == old => *id = new,
|
||||
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
|
||||
Expr::Call { definition, .. } => remap_ref(definition, old, new),
|
||||
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
|
||||
_ => {} // Parameter nodes and argument keys are scoped, never global.
|
||||
});
|
||||
}
|
||||
}
|
||||
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
|
||||
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check before any mutation so a map-key collision never silently discards a binding.
|
||||
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
|
||||
if old != new {
|
||||
for element in project.elements.values() {
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
|
||||
&& overrides.contains_key(&old)
|
||||
&& overrides.contains_key(&new)
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
element.id,
|
||||
"overrides",
|
||||
"analysis_reference_collision",
|
||||
"Reidentification would merge two distinct override keys.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
|
||||
for element in project.elements.values_mut() {
|
||||
match element.shared_mut() {
|
||||
Some(SharedBody::EngineeringProperty {
|
||||
subject,
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
}) => {
|
||||
remap_subject(subject, old, new);
|
||||
remap_type(value_type, old, new);
|
||||
if let Some(reference) = display_unit {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
remap_binding(base, old, new);
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if old != new
|
||||
&& let Some(value) = overrides.remove(&old)
|
||||
{
|
||||
overrides.insert(new, value);
|
||||
}
|
||||
for value in overrides.values_mut() {
|
||||
remap_binding(value, old, new);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if *documented_constraint == Some(old) {
|
||||
*documented_constraint = Some(new);
|
||||
}
|
||||
remap_binding(predicate, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values_mut() {
|
||||
match &mut definition.body {
|
||||
DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
algebra: QuantityAlgebra::Affine { difference },
|
||||
..
|
||||
}) => remap_ref(difference, old, new),
|
||||
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
|
||||
DefinitionBody::Calculation(calculation) => {
|
||||
for parameter in &mut calculation.parameters {
|
||||
remap_type(&mut parameter.value_type, old, new);
|
||||
}
|
||||
remap_type(&mut calculation.result, old, new);
|
||||
remap_binding(&mut calculation.body, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
|
||||
use crate::{Id, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
|
||||
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct KindHandle {
|
||||
model: Id,
|
||||
definition: Id,
|
||||
}
|
||||
impl KindHandle {
|
||||
pub fn definition(self) -> Id {
|
||||
self.definition
|
||||
}
|
||||
pub(crate) fn local(model: Id, definition: Id) -> Self {
|
||||
Self { model, definition }
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedScalar {
|
||||
Boolean,
|
||||
Numeric(NumericType<KindHandle>),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedType {
|
||||
Scalar(ResolvedScalar),
|
||||
List(ResolvedScalar),
|
||||
}
|
||||
impl ResolvedType {
|
||||
pub(crate) fn boolean() -> Self {
|
||||
Self::Scalar(ResolvedScalar::Boolean)
|
||||
}
|
||||
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
|
||||
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EffectiveBinding<'a> {
|
||||
pub binding: &'a Binding,
|
||||
pub origin: BindingOrigin,
|
||||
}
|
||||
|
||||
/// Constructed only by admission; exact source/capture provenance stays with its owner.
|
||||
/// Values from different accepted environments must not be mixed by an evaluator.
|
||||
pub struct ResolvedAnalysis<'p> {
|
||||
pub(super) project: &'p Project,
|
||||
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
|
||||
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
|
||||
pub(super) limits: AdmissionLimits,
|
||||
pub(super) usage: AdmissionUsage,
|
||||
}
|
||||
impl<'p> ResolvedAnalysis<'p> {
|
||||
pub fn project(&self) -> &'p Project {
|
||||
self.project
|
||||
}
|
||||
pub fn admission_usage(&self) -> AdmissionUsage {
|
||||
self.usage
|
||||
}
|
||||
pub fn admission_limits(&self) -> AdmissionLimits {
|
||||
self.limits
|
||||
}
|
||||
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
|
||||
self.project.definitions.get(&reference.local())
|
||||
}
|
||||
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
|
||||
self.kinds.get(&reference.local())
|
||||
}
|
||||
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
|
||||
self.units.get(&reference.local())
|
||||
}
|
||||
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
|
||||
match &self.definition(reference)?.body {
|
||||
DefinitionBody::Calculation(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
let scalar = match scalar {
|
||||
ScalarType::Boolean => ResolvedScalar::Boolean,
|
||||
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
|
||||
ScalarType::Quantity(reference) => {
|
||||
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
|
||||
}
|
||||
};
|
||||
Some(match value {
|
||||
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
|
||||
AnalysisType::List(_) => ResolvedType::List(scalar),
|
||||
})
|
||||
}
|
||||
pub fn effective_binding(
|
||||
&self,
|
||||
property: Id,
|
||||
context: EvaluationContext,
|
||||
) -> Result<EffectiveBinding<'p>, AnalysisError> {
|
||||
let element = self.project.elements.get(&property).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Property does not exist.",
|
||||
)
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Target is not an engineering property.",
|
||||
));
|
||||
};
|
||||
if let EvaluationContext::Case(id) = context {
|
||||
let case = self.project.elements.get(&id).ok_or_else(|| {
|
||||
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_case",
|
||||
"Context is not an analysis case.",
|
||||
));
|
||||
};
|
||||
if case.layer() != element.layer() {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_layer",
|
||||
"Case and property must share a layer.",
|
||||
));
|
||||
}
|
||||
if let Some(binding) = overrides.get(&property) {
|
||||
return Ok(EffectiveBinding {
|
||||
binding,
|
||||
origin: BindingOrigin::CaseOverride(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(EffectiveBinding {
|
||||
binding: base,
|
||||
origin: BindingOrigin::ModelBaseline,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,458 @@
|
||||
//! Static expression checking shares numeric operator algebra with execution.
|
||||
use super::admission::Budget;
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
|
||||
use crate::{Id, Layer, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) struct BindingContext<'a> {
|
||||
pub owner: Id,
|
||||
pub field: &'a str,
|
||||
pub layer: Option<Layer>,
|
||||
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
}
|
||||
|
||||
pub(super) fn binding(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
context: BindingContext<'_>,
|
||||
value: &Binding,
|
||||
expected: &ResolvedType,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
if let Binding::Expression(expression) = value {
|
||||
let mut checker = Checker {
|
||||
environment,
|
||||
owner: context.owner,
|
||||
field: context.field,
|
||||
layer: context.layer,
|
||||
parameters: context.parameters,
|
||||
budget,
|
||||
};
|
||||
checker.expression(expression, Some(expected))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Checker<'a, 'p, 'b> {
|
||||
environment: &'a ResolvedAnalysis<'p>,
|
||||
owner: Id,
|
||||
field: &'a str,
|
||||
layer: Option<Layer>,
|
||||
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
budget: &'b mut Budget,
|
||||
}
|
||||
|
||||
/// One suspended expression per logical depth. No Expr is owned or cloned by
|
||||
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
|
||||
struct Frame<'e, 'p> {
|
||||
expression: &'e Expr,
|
||||
expected: Option<ResolvedType>,
|
||||
state: State<'p>,
|
||||
}
|
||||
|
||||
enum State<'p> {
|
||||
Enter,
|
||||
Unary,
|
||||
BinaryLeft,
|
||||
BinaryRight(ResolvedType),
|
||||
IfCondition,
|
||||
IfTrue,
|
||||
IfFalse(ResolvedType),
|
||||
List {
|
||||
next: usize,
|
||||
},
|
||||
Call {
|
||||
calculation: &'p CalculationDefinition,
|
||||
next: usize,
|
||||
},
|
||||
SumInitial,
|
||||
SumValues(ResolvedType),
|
||||
Power,
|
||||
AsKind(ResolvedKind<KindHandle>),
|
||||
}
|
||||
|
||||
enum Step<'e> {
|
||||
Child(&'e Expr, Option<ResolvedType>),
|
||||
Complete(ResolvedType),
|
||||
}
|
||||
|
||||
impl<'p> Checker<'_, 'p, '_> {
|
||||
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
|
||||
AnalysisError::new(self.owner, self.field, code, message)
|
||||
}
|
||||
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
|
||||
match value {
|
||||
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
|
||||
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
|
||||
}
|
||||
}
|
||||
fn operation(
|
||||
&self,
|
||||
result: Result<NumericType<KindHandle>, NumericError>,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
result
|
||||
.map(ResolvedType::numeric)
|
||||
.map_err(|error| self.error("analysis_type", error.to_string()))
|
||||
}
|
||||
|
||||
/// Explicit continuations replace Rust recursion. Width never expands the
|
||||
/// frame stack: lists and calls schedule only their next ordered child.
|
||||
/// At most 128 frames and one returned scalar/list type are retained. The
|
||||
/// per-node/per-argument meter charges remain identical to recursive entry;
|
||||
/// continuation steps do constant bounded work within those charges.
|
||||
fn expression(
|
||||
&mut self,
|
||||
expression: &Expr,
|
||||
expected: Option<&ResolvedType>,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
let mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected: expected.cloned(),
|
||||
state: State::Enter,
|
||||
});
|
||||
let mut returned = None;
|
||||
loop {
|
||||
let frame = frames.last_mut().expect("active expression frame");
|
||||
match self.advance(frame, returned.take())? {
|
||||
Step::Child(expression, expected) => {
|
||||
if frames.len() == MAX_EXPRESSION_DEPTH {
|
||||
// Preserve entry charging/error precedence without ever
|
||||
// allocating the rejected 129th frame.
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return Err(self.error(
|
||||
"analysis_depth",
|
||||
"Expression exceeds the logical depth limit.",
|
||||
));
|
||||
}
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected,
|
||||
state: State::Enter,
|
||||
});
|
||||
}
|
||||
Step::Complete(actual) => {
|
||||
let frame = frames.pop().expect("completed expression frame");
|
||||
let expected = frame.expected.as_ref();
|
||||
if expected.is_some_and(|expected| expected != &actual) {
|
||||
return Err(self.error(
|
||||
"analysis_type",
|
||||
format!("Expected {expected:?}; expression has {actual:?}."),
|
||||
));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Ok(actual);
|
||||
}
|
||||
returned = Some(actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance<'e>(
|
||||
&mut self,
|
||||
frame: &mut Frame<'e, 'p>,
|
||||
returned: Option<ResolvedType>,
|
||||
) -> Result<Step<'e>, AnalysisError> {
|
||||
let state = std::mem::replace(&mut frame.state, State::Enter);
|
||||
if let State::Enter = state {
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return self.enter(frame);
|
||||
}
|
||||
let child = returned.expect("continuation follows a completed child");
|
||||
match state {
|
||||
State::Enter => unreachable!("handled before consuming child"),
|
||||
State::Unary => {
|
||||
let Expr::Unary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(match op {
|
||||
UnaryOp::Not => ResolvedType::boolean(),
|
||||
UnaryOp::Negate | UnaryOp::Absolute => {
|
||||
self.operation(self.numeric(child)?.unary_result())?
|
||||
}
|
||||
}))
|
||||
}
|
||||
State::BinaryLeft => {
|
||||
let Expr::Binary { right, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::BinaryRight(child);
|
||||
Ok(Step::Child(right, None))
|
||||
}
|
||||
State::BinaryRight(left) => {
|
||||
let Expr::Binary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(self.binary(*op, left, child)?))
|
||||
}
|
||||
State::IfCondition => {
|
||||
let Expr::If { when_true, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfTrue;
|
||||
Ok(Step::Child(when_true, frame.expected.clone()))
|
||||
}
|
||||
State::IfTrue => {
|
||||
let Expr::If { when_false, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfFalse(child.clone());
|
||||
Ok(Step::Child(when_false, Some(child)))
|
||||
}
|
||||
State::IfFalse(value) => Ok(Step::Complete(value)),
|
||||
State::List { next } => {
|
||||
let ResolvedType::Scalar(scalar) = child else {
|
||||
return Err(self.error(
|
||||
"analysis_list",
|
||||
"Lists are flat; nested lists are not admitted.",
|
||||
));
|
||||
};
|
||||
let Expr::List(items) = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(item) = items.get(next) {
|
||||
frame.state = State::List { next: next + 1 };
|
||||
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
|
||||
} else {
|
||||
Ok(Step::Complete(ResolvedType::List(scalar)))
|
||||
}
|
||||
}
|
||||
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
|
||||
State::SumInitial => {
|
||||
let numeric = self.numeric(child.clone())?;
|
||||
self.operation(numeric.unary_result())?;
|
||||
let ResolvedType::Scalar(scalar) = &child else {
|
||||
unreachable!()
|
||||
};
|
||||
let expected = ResolvedType::List(scalar.clone());
|
||||
let Expr::Sum { values, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::SumValues(child);
|
||||
Ok(Step::Child(values, Some(expected)))
|
||||
}
|
||||
State::SumValues(initial) => {
|
||||
let numeric = self.numeric(initial.clone())?;
|
||||
self.operation(numeric.additive_result(&numeric, false))?;
|
||||
Ok(Step::Complete(initial))
|
||||
}
|
||||
State::Power => {
|
||||
let Expr::Power { exponent, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.power_result(*exponent))?,
|
||||
))
|
||||
}
|
||||
State::AsKind(kind) => Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.assertion_result(&kind))?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
|
||||
let actual = match frame.expression {
|
||||
Expr::Boolean(_) => ResolvedType::boolean(),
|
||||
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
|
||||
Expr::Quantity { unit, .. } => {
|
||||
let unit = self.environment.unit(*unit).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_unit",
|
||||
"Quantity literal must reference an existing unit definition.",
|
||||
)
|
||||
})?;
|
||||
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
|
||||
}
|
||||
Expr::Property(id) => {
|
||||
let layer = self.layer.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_purity",
|
||||
"Reusable calculations cannot capture project properties.",
|
||||
)
|
||||
})?;
|
||||
let element =
|
||||
self.environment.project.elements.get(id).ok_or_else(|| {
|
||||
self.error("analysis_property", "Property does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
|
||||
else {
|
||||
return Err(self.error(
|
||||
"analysis_property",
|
||||
"Reference must target an engineering property.",
|
||||
));
|
||||
};
|
||||
if element.layer() != layer {
|
||||
return Err(self.error(
|
||||
"analysis_layer",
|
||||
"Property references must stay in the same layer.",
|
||||
));
|
||||
}
|
||||
self.environment.declared_type(value_type).ok_or_else(|| {
|
||||
self.error("analysis_kind", "Declared property kind does not resolve.")
|
||||
})?
|
||||
}
|
||||
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_parameter",
|
||||
"Parameter does not belong to this calculation definition.",
|
||||
)
|
||||
})?,
|
||||
Expr::List(items) => {
|
||||
if items.len() > MAX_LIST_LENGTH {
|
||||
return Err(
|
||||
self.error("analysis_list", "List exceeds the flat-list length limit.")
|
||||
);
|
||||
}
|
||||
let scalar = match &frame.expected {
|
||||
Some(ResolvedType::List(value)) => Some(value.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = items.first() {
|
||||
frame.state = State::List { next: 1 };
|
||||
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
|
||||
}
|
||||
ResolvedType::List(scalar.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_list",
|
||||
"An empty list requires an explicit surrounding list type.",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
Expr::Unary { op, operand } => {
|
||||
frame.state = State::Unary;
|
||||
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
|
||||
return Ok(Step::Child(operand, expected));
|
||||
}
|
||||
Expr::Binary { left, .. } => {
|
||||
frame.state = State::BinaryLeft;
|
||||
return Ok(Step::Child(left, None));
|
||||
}
|
||||
Expr::If { condition, .. } => {
|
||||
frame.state = State::IfCondition;
|
||||
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
|
||||
}
|
||||
Expr::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_calculation",
|
||||
"Call must reference a calculation definition.",
|
||||
)
|
||||
})?;
|
||||
if arguments.len() != calculation.parameters.len() {
|
||||
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
|
||||
}
|
||||
// Charge the complete key scan before any parameter lookup.
|
||||
self.budget
|
||||
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
|
||||
if calculation
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|parameter| !arguments.contains_key(¶meter.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(¶meter.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[¶meter.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),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
|
||||
use crate::Id;
|
||||
use crate::analysis_numeric::{Dimension, Exact};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Only real local definitions are admitted in schema 8's first environment.
|
||||
/// Imported references require the separately implemented pinned package catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionRef {
|
||||
Local(Id),
|
||||
}
|
||||
impl DefinitionRef {
|
||||
pub fn local(self) -> Id {
|
||||
match self {
|
||||
Self::Local(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Qualified by its owning CalculationDefinition; never a global model identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ParameterId(pub Id);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Definition {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: DefinitionBody,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionBody {
|
||||
QuantityKind(QuantityKindDefinition),
|
||||
Unit(UnitDefinition),
|
||||
Calculation(CalculationDefinition),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct QuantityKindDefinition {
|
||||
pub dimension: Dimension,
|
||||
pub algebra: QuantityAlgebra,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum QuantityAlgebra {
|
||||
Linear,
|
||||
Affine { difference: DefinitionRef },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnitDefinition {
|
||||
pub kind: DefinitionRef,
|
||||
pub scale: Exact,
|
||||
pub offset: Exact,
|
||||
pub symbol: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ScalarType {
|
||||
Boolean,
|
||||
Number,
|
||||
Quantity(DefinitionRef),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisType {
|
||||
Scalar(ScalarType),
|
||||
List(ScalarType),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Parameter {
|
||||
pub id: ParameterId,
|
||||
pub name: String,
|
||||
pub value_type: AnalysisType,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CalculationDefinition {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub result: AnalysisType,
|
||||
pub body: Binding,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Binding {
|
||||
Unbound,
|
||||
Expression(Expr),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum UnaryOp {
|
||||
Negate,
|
||||
Absolute,
|
||||
Not,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BinaryOp {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Equal,
|
||||
NotEqual,
|
||||
Less,
|
||||
LessOrEqual,
|
||||
Greater,
|
||||
GreaterOrEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
pub enum Expr {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: DefinitionRef,
|
||||
},
|
||||
Property(Id),
|
||||
Parameter(ParameterId),
|
||||
List(Vec<Expr>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expr>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expr>,
|
||||
when_true: Box<Expr>,
|
||||
when_false: Box<Expr>,
|
||||
},
|
||||
Call {
|
||||
definition: DefinitionRef,
|
||||
arguments: BTreeMap<ParameterId, Expr>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expr>,
|
||||
initial: Box<Expr>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expr>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: DefinitionRef,
|
||||
value: Box<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisSubject {
|
||||
Element(Id),
|
||||
Relation(Id),
|
||||
}
|
||||
impl AnalysisSubject {
|
||||
pub fn id(self) -> Id {
|
||||
match self {
|
||||
Self::Element(id) | Self::Relation(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BaselineBase {
|
||||
ModelBaseline,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum EvaluationContext {
|
||||
ModelBaseline,
|
||||
Case(Id),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum BindingOrigin {
|
||||
ModelBaseline,
|
||||
CaseOverride(Id),
|
||||
}
|
||||
|
||||
/// Located structural/static admission failure, not a false or failed evaluation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[error("{code} at {object}.{field}: {message}")]
|
||||
pub struct AnalysisError {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
impl AnalysisError {
|
||||
pub(crate) fn new(
|
||||
object: Id,
|
||||
field: impl Into<String>,
|
||||
code: &str,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
object,
|
||||
field: field.into(),
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
pub fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericType<K> {
|
||||
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
|
||||
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
|
||||
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
|
||||
(None, None) if a == b => Ok(Named(a.clone())),
|
||||
(Some(difference), Some(_)) if subtract && a == b => {
|
||||
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
|
||||
}
|
||||
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
|
||||
Ok(Named(a.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract && difference == &a.key && a.dimension == b.dimension =>
|
||||
{
|
||||
Ok(Named(b.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::Number;
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
if left.is_affine() || right.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(left),
|
||||
(Number, _) if !divide => Ok(right),
|
||||
_ => Ok(Self::anonymous(if divide {
|
||||
left.dimension().checked_quotient(right.dimension())?
|
||||
} else {
|
||||
left.dimension().checked_product(right.dimension())?
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unary_result(&self) -> Result<Self, NumericError> {
|
||||
if self.is_affine() {
|
||||
Err(NumericError::AffineOperation)
|
||||
} else {
|
||||
Ok(self.clone().normalized())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(match exponent {
|
||||
0 => Self::Number,
|
||||
1 => self.clone().normalized(),
|
||||
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
|
||||
if self.clone().normalized() == rhs.clone().normalized() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
|
||||
if target.is_affine()
|
||||
|| matches!(self, Self::Named(_))
|
||||
|| self.dimension() != target.dimension
|
||||
{
|
||||
Err(NumericError::KindAssertion)
|
||||
} else {
|
||||
Ok(Self::Named(target.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.negated(meter)?))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.absolute(meter)?))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.power_result(exponent)?;
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
self.value_type.comparison_compatible(&rhs.value_type)?;
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.assertion_result(target)?;
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(output, self.coordinate.clone()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
+216
-274
@@ -1,6 +1,12 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||
mod raw_tokens;
|
||||
#[path = "language/semantic_source.rs"]
|
||||
mod semantic_source;
|
||||
mod source3_bridge;
|
||||
// Staged syntax foundation only. Public source8 admission waits for complete
|
||||
// declaration lowering, source updates and the root-owned resource gate.
|
||||
#[cfg(test)]
|
||||
mod analysis_source;
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -60,7 +66,8 @@ impl ParseLimits {
|
||||
let graph = project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len());
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(project.definitions.len());
|
||||
let records = graph
|
||||
.saturating_add(1)
|
||||
.saturating_add(project.diagrams.len())
|
||||
@@ -79,7 +86,8 @@ impl ParseLimits {
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
.saturating_add(project.traces.len());
|
||||
.saturating_add(project.traces.len())
|
||||
.saturating_add(crate::analysis::nested_record_count(project));
|
||||
let steps = project
|
||||
.elements
|
||||
.values()
|
||||
@@ -142,164 +150,10 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
cursor += 1;
|
||||
while source
|
||||
.as_bytes()
|
||||
.get(cursor)
|
||||
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
TokenClass::Whitespace
|
||||
} else if source[cursor..].starts_with("//") {
|
||||
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
|
||||
TokenClass::Comment
|
||||
} else if ch == '"' {
|
||||
cursor += 1;
|
||||
let mut escaped = false;
|
||||
let mut closed = false;
|
||||
while cursor < source.len() {
|
||||
let next = source[cursor..].chars().next().expect("in bounds");
|
||||
cursor += next.len_utf8();
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if next == '\\' {
|
||||
escaped = true;
|
||||
} else if next == '"' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
|
||||
TokenClass::String
|
||||
} else {
|
||||
TokenClass::Error
|
||||
}
|
||||
} else if ch.is_ascii_alphabetic() || ch == '_' {
|
||||
cursor += 1;
|
||||
while source
|
||||
.as_bytes()
|
||||
.get(cursor)
|
||||
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if reserved(&source[start..cursor])
|
||||
|| matches!(&source[start..cursor], "true" | "false" | "none")
|
||||
{
|
||||
TokenClass::Keyword
|
||||
} else {
|
||||
TokenClass::Identifier
|
||||
}
|
||||
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
|
||||
cursor += 1;
|
||||
while source.as_bytes().get(cursor).is_some_and(|next| {
|
||||
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
|
||||
}) {
|
||||
cursor += 1;
|
||||
}
|
||||
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
|
||||
TokenClass::Number
|
||||
} else {
|
||||
TokenClass::Error
|
||||
}
|
||||
} else if "{}();,[]:=@".contains(ch) {
|
||||
cursor += ch.len_utf8();
|
||||
TokenClass::Punctuation
|
||||
} else {
|
||||
cursor += ch.len_utf8();
|
||||
TokenClass::Error
|
||||
};
|
||||
tokens.push(TokenSpan {
|
||||
class,
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
Ok(tokens)
|
||||
raw_tokens::legacy(source)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -626,6 +480,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits,
|
||||
#[cfg(test)]
|
||||
mixed: None,
|
||||
}
|
||||
.document()
|
||||
.map_err(|e| vec![diagnostic(source, e)])
|
||||
@@ -1060,6 +916,8 @@ pub(crate) fn parse_unit(
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits,
|
||||
#[cfg(test)]
|
||||
mixed: None,
|
||||
}
|
||||
.unit(entry, true)
|
||||
.map_err(|e| Box::new(diagnostic(source, e)))
|
||||
@@ -1151,31 +1009,52 @@ fn edit_positions(
|
||||
unit: SourceUnit,
|
||||
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
||||
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
||||
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
||||
edit_positions_from_tokens(source, &unit, |at| tokens[at], tokens.len(), None)
|
||||
}
|
||||
|
||||
fn edit_positions_from_tokens(
|
||||
source: &str,
|
||||
unit: &SourceUnit,
|
||||
token: impl Fn(usize) -> Lexeme,
|
||||
count: usize,
|
||||
value_ends: Option<&BTreeMap<usize, usize>>,
|
||||
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
||||
let mut ends: Vec<_> = (0..count).map(|at| token(at).span.end).collect();
|
||||
let mut stack = Vec::new();
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
for index in 0..count {
|
||||
let token = token(index);
|
||||
match token.text(source) {
|
||||
"{" | "[" => stack.push(index),
|
||||
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
struct Positions<'a> {
|
||||
tokens: &'a [Lexeme],
|
||||
struct Positions<'a, F> {
|
||||
token_at: F,
|
||||
ends: Vec<usize>,
|
||||
value_ends: Option<&'a BTreeMap<usize, usize>>,
|
||||
}
|
||||
impl Positions<'_> {
|
||||
impl<F: Fn(usize) -> Lexeme> Positions<'_, F> {
|
||||
fn token(&self, start: usize) -> usize {
|
||||
self.tokens
|
||||
.partition_point(|token| token.span.start < start)
|
||||
let mut low = 0;
|
||||
let mut high = self.ends.len();
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2;
|
||||
if (self.token_at)(middle).span.start < start {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
low
|
||||
}
|
||||
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, ast)| {
|
||||
let value = self.value(ast);
|
||||
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
||||
let end = self.tokens[self.token(value.span.end)].span.end;
|
||||
let start = (self.token_at)(self.token(value.span.start) - 1).span.start;
|
||||
let end = (self.token_at)(self.token(value.span.end)).span.end;
|
||||
(
|
||||
key.clone(),
|
||||
EditField {
|
||||
@@ -1190,10 +1069,15 @@ fn edit_positions(
|
||||
let index = self.token(ast.offset());
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
end: match ast {
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
end: self
|
||||
.value_ends
|
||||
.and_then(|ends| ends.get(&ast.offset()).copied())
|
||||
.unwrap_or_else(|| match ast {
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => {
|
||||
(self.token_at)(index + 1).span.end
|
||||
}
|
||||
_ => self.ends[index],
|
||||
}),
|
||||
};
|
||||
let kind = match ast {
|
||||
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
||||
@@ -1208,11 +1092,12 @@ fn edit_positions(
|
||||
}
|
||||
}
|
||||
let positions = Positions {
|
||||
tokens: &tokens,
|
||||
token_at: &token,
|
||||
ends,
|
||||
value_ends,
|
||||
};
|
||||
unit.declarations
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|declaration| {
|
||||
if declaration.id_insertion.is_some() {
|
||||
return Err(Box::new(diagnostic(
|
||||
@@ -1232,8 +1117,8 @@ fn edit_positions(
|
||||
let start = positions.token(declaration.span.start);
|
||||
let header = roles
|
||||
.iter()
|
||||
.zip(&tokens[start..])
|
||||
.map(|(role, token)| (*role, token.span))
|
||||
.zip(start..count)
|
||||
.map(|(role, at)| (*role, token(at).span))
|
||||
.collect();
|
||||
Ok((
|
||||
declaration.id,
|
||||
@@ -1260,10 +1145,35 @@ struct Parser<'a> {
|
||||
cursor: usize,
|
||||
value_depth: usize,
|
||||
limits: ParseLimits,
|
||||
#[cfg(test)]
|
||||
mixed: Option<analysis_source::mixed::State<'a>>,
|
||||
}
|
||||
impl Parser<'_> {
|
||||
fn token(&self, index: usize) -> Option<Lexeme> {
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &self.mixed {
|
||||
return mixed.token(index);
|
||||
}
|
||||
self.tokens.get(index).copied()
|
||||
}
|
||||
fn current(&self) -> Lexeme {
|
||||
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
||||
self.token(self.cursor).unwrap_or(Lexeme {
|
||||
class: TokenClass::Punctuation,
|
||||
span: Span {
|
||||
start: self.source.len(),
|
||||
end: self.source.len(),
|
||||
},
|
||||
})
|
||||
}
|
||||
fn syntax_entry(&mut self) -> Result<(), Error> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let span = self.current().span;
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.entry(span)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn text(&self) -> &str {
|
||||
self.current().text(self.source)
|
||||
@@ -1300,12 +1210,20 @@ impl Parser<'_> {
|
||||
}
|
||||
fn optional_id(&mut self) -> Result<Id, Error> {
|
||||
if self.text() != "id" {
|
||||
#[cfg(test)]
|
||||
if self.mixed.is_some() {
|
||||
return Err(self.error("Private mixed syntax requires explicit IDs."));
|
||||
}
|
||||
return Ok(Id::new_v4());
|
||||
}
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if !has_uuid_header_shape(&value) {
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1336,6 +1254,7 @@ impl Parser<'_> {
|
||||
Ok(fields)
|
||||
}
|
||||
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
|
||||
self.syntax_entry()?;
|
||||
let key = self.text().to_owned();
|
||||
if !matches!(
|
||||
self.current().class,
|
||||
@@ -1346,15 +1265,19 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let start = self.current().span.start;
|
||||
let value = self.value()?;
|
||||
let end = self.tokens[self.cursor - 1].span.end;
|
||||
let end = self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end;
|
||||
self.take(";")?;
|
||||
Ok((key, value, Span { start, end }))
|
||||
}
|
||||
|
||||
fn nested_header_kind(&self) -> Option<DeclKind> {
|
||||
let kind = declaration_kind(self.text())?;
|
||||
let alias = self.tokens.get(self.cursor + 1)?;
|
||||
let following = self.tokens.get(self.cursor + 2)?;
|
||||
let alias = self.token(self.cursor + 1)?;
|
||||
let following = self.token(self.cursor + 2)?;
|
||||
// A node's ordinary `diagram @view;` field is not a declaration.
|
||||
if !crate::valid_alias(alias.text(self.source)) {
|
||||
return None;
|
||||
@@ -1369,14 +1292,33 @@ impl Parser<'_> {
|
||||
if self.value_depth >= 64 {
|
||||
return Err(self.error("Typed values may not nest more than 64 levels."));
|
||||
}
|
||||
self.syntax_entry()?;
|
||||
self.value_depth += 1;
|
||||
let result = self.value_inner();
|
||||
self.value_depth -= 1;
|
||||
#[cfg(test)]
|
||||
if let Ok(ast) = &result {
|
||||
let end = self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed value")
|
||||
.span
|
||||
.end;
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.value_ends.insert(ast.offset(), end);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Ast, Error> {
|
||||
let token = self.current();
|
||||
let offset = token.span.start;
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &self.mixed
|
||||
&& (token.class == TokenClass::Number || matches!(self.text(), "-" | "+"))
|
||||
{
|
||||
let value = mixed.ordinary_number(&mut self.cursor)?;
|
||||
return Ok(Ast::Literal(value, offset));
|
||||
}
|
||||
match self.text() {
|
||||
"uuid" => {
|
||||
self.cursor += 1;
|
||||
@@ -1474,16 +1416,31 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
||||
return Err(
|
||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
||||
);
|
||||
#[cfg(test)]
|
||||
let mixed_mode = self.mixed.is_some();
|
||||
#[cfg(not(test))]
|
||||
let mixed_mode = false;
|
||||
if if mixed_mode {
|
||||
self.text() != "8"
|
||||
} else {
|
||||
!matches!(self.text(), "3" | "4" | "5" | "6" | "7")
|
||||
} {
|
||||
return Err(self.error(if mixed_mode {
|
||||
"Private mixed syntax requires arcadia 8."
|
||||
} else {
|
||||
"Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7."
|
||||
}));
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
self.take("project")?;
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.record(false, Span { start, end: start })?;
|
||||
}
|
||||
self.syntax_entry()?;
|
||||
let alias = self.identifier()?;
|
||||
let name = self.quoted()?;
|
||||
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
||||
@@ -1497,6 +1454,7 @@ impl Parser<'_> {
|
||||
alias,
|
||||
name,
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1523,6 +1481,14 @@ impl Parser<'_> {
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
loop {
|
||||
let offset = self.current().span.start;
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &mut self.mixed
|
||||
&& mixed.typed_header(self.cursor, open.is_empty())
|
||||
{
|
||||
let owner = open.last().map(|&at| declarations[at].id);
|
||||
mixed.parse_typed(&mut self.cursor, owner)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(&owner) = open.last() {
|
||||
if self.text() == "}" {
|
||||
declarations[owner].body.end = offset;
|
||||
@@ -1566,6 +1532,14 @@ impl Parser<'_> {
|
||||
if !includes_allowed {
|
||||
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.include(Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
})?;
|
||||
}
|
||||
self.syntax_entry()?;
|
||||
self.cursor += 1;
|
||||
let path = self.quoted()?;
|
||||
self.take(";")?;
|
||||
@@ -1573,7 +1547,11 @@ impl Parser<'_> {
|
||||
path,
|
||||
Span {
|
||||
start: offset,
|
||||
end: self.tokens[self.cursor - 1].span.end,
|
||||
end: self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end,
|
||||
},
|
||||
));
|
||||
continue;
|
||||
@@ -1585,20 +1563,40 @@ impl Parser<'_> {
|
||||
if entry_header.description.is_some() {
|
||||
return Err(self.error("Duplicate project description."));
|
||||
}
|
||||
self.syntax_entry()?;
|
||||
self.cursor += 1;
|
||||
let description = self.quoted()?;
|
||||
self.take(";")?;
|
||||
entry_header.project.description = description;
|
||||
entry_header.description = Some(Span {
|
||||
start: offset,
|
||||
end: self.tokens[self.cursor - 1].span.end,
|
||||
end: self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end,
|
||||
});
|
||||
header = Some(entry_header);
|
||||
continue;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.mixed.is_some() && analysis_source::mixed::long_analysis_word(self.text()) {
|
||||
return Err(self.error("Use the short source8 analysis declaration word."));
|
||||
}
|
||||
self.syntax_entry()?;
|
||||
let word = self.text().to_owned();
|
||||
let kind = declaration_kind(&word)
|
||||
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.record(
|
||||
matches!(kind, DeclKind::Element(..) | DeclKind::Relation(..)),
|
||||
Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
graph_count += usize::from(matches!(
|
||||
kind,
|
||||
DeclKind::Element(..) | DeclKind::Relation(..)
|
||||
@@ -1626,6 +1624,10 @@ impl Parser<'_> {
|
||||
let body_start = self.current().span.end;
|
||||
self.take("{")?;
|
||||
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
|
||||
#[cfg(test)]
|
||||
if let Some(mixed) = &mut self.mixed {
|
||||
mixed.lexical_owners.push(open.last().copied());
|
||||
}
|
||||
declarations.push(Declaration {
|
||||
kind,
|
||||
alias,
|
||||
@@ -1717,6 +1719,9 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
parent = Some(owner);
|
||||
}
|
||||
if kind.is_analysis() {
|
||||
return Err(conversion_error("Analysis records require the source8 authoring implementation.".into()));
|
||||
}
|
||||
let mut initial = if kind == Kind::Port {
|
||||
if !fields.contains_key("contract") {
|
||||
return Err(conversion_error(
|
||||
@@ -1895,10 +1900,7 @@ pub(crate) fn resolve_declarations(
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
if legacy {
|
||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
||||
value["elements"] = Value::Object(legacy_elements);
|
||||
let migrated = serde_json::from_value(value)
|
||||
.map_err(crate::persistence::FileError::Json)
|
||||
let migrated = source3_bridge::from_scratch(project, legacy_elements)
|
||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||
.map_err(|error| {
|
||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||
@@ -1946,13 +1948,15 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
if matches!(project.schema_version, 4..=7) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else {
|
||||
} else if project.schema_version == 6 {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v7(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
@@ -2033,49 +2037,8 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -2084,21 +2047,17 @@ fn declaration_outline(
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -2123,16 +2082,7 @@ fn declaration_outline(
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -2140,51 +2090,39 @@ fn declaration_outline(
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2579,7 +2517,11 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Unresolved typed source8 syntax, staged for the complete source adapter.
|
||||
//! This module does not admit a Project or enable public source8.
|
||||
mod budget;
|
||||
mod cursor;
|
||||
mod declarations;
|
||||
mod index;
|
||||
mod lex;
|
||||
mod lower;
|
||||
pub(super) mod mixed;
|
||||
mod syntax;
|
||||
mod update;
|
||||
mod write;
|
||||
|
||||
#[cfg(test)]
|
||||
mod model_oracle;
|
||||
|
||||
use super::Span;
|
||||
use crate::analysis::{BinaryOp, UnaryOp};
|
||||
use crate::analysis_numeric::Exact;
|
||||
use budget::Budget;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Error {
|
||||
pub span: Span,
|
||||
pub message: String,
|
||||
}
|
||||
fn error(span: Span, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
span,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Reference {
|
||||
pub name: String,
|
||||
pub span: Span,
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Argument {
|
||||
pub parameter: Reference,
|
||||
pub value: Expression,
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Expression {
|
||||
pub kind: ExpressionKind,
|
||||
pub span: Span,
|
||||
pub depth: usize,
|
||||
pub grouped: bool,
|
||||
/// Original numeric tokens, including constructor punctuation, excluding
|
||||
/// trivia. The full span retains intervening comments and spelling.
|
||||
pub literal: Option<LiteralSyntax>,
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LiteralSyntax {
|
||||
pub span: Span,
|
||||
pub tokens: Vec<Span>,
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExpressionKind {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: Reference,
|
||||
},
|
||||
Property(Reference),
|
||||
Parameter(Reference),
|
||||
List(Vec<Expression>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expression>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expression>,
|
||||
right: Box<Expression>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expression>,
|
||||
when_true: Box<Expression>,
|
||||
when_false: Box<Expression>,
|
||||
},
|
||||
Call {
|
||||
definition: Reference,
|
||||
arguments: Vec<Argument>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expression>,
|
||||
initial: Box<Expression>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expression>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: Reference,
|
||||
value: Box<Expression>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Binding {
|
||||
Unbound(Span),
|
||||
Expression(Expression),
|
||||
}
|
||||
|
||||
pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
|
||||
let tokens = lex::tokens(source, budget)?;
|
||||
let mut cursor = cursor::Cursor::new(source, &tokens, budget);
|
||||
let binding = syntax::binding(&mut cursor)?;
|
||||
cursor.end()?;
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Candidate limits for measured adoption; one owner spans all parsed files.
|
||||
use super::{Error, Span, error};
|
||||
use crate::analysis_numeric::{Exact, WorkLimits, WorkMeter};
|
||||
use crate::language::ParseLimits;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub references: usize,
|
||||
pub frames: usize,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
impl Limits {
|
||||
pub fn source8(caller: ParseLimits) -> Self {
|
||||
let bytes = caller.max_source_bytes.min(64 * 1024 * 1024);
|
||||
Self {
|
||||
bytes,
|
||||
tokens: bytes.min(1_048_576),
|
||||
entries: caller.max_records.min(262_144),
|
||||
references: caller.max_step_references.min(500_000),
|
||||
frames: 256,
|
||||
numeric_units: 50_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub references: usize,
|
||||
pub deepest_frame: usize,
|
||||
}
|
||||
pub struct Budget {
|
||||
limits: Limits,
|
||||
pub usage: Usage,
|
||||
numeric: WorkMeter,
|
||||
failed: Option<Error>,
|
||||
}
|
||||
impl Budget {
|
||||
pub fn new(limits: Limits) -> Result<Self, Error> {
|
||||
let hard = Limits::source8(ParseLimits::UNLIMITED);
|
||||
if limits.bytes > hard.bytes
|
||||
|| limits.tokens > hard.tokens
|
||||
|| limits.entries > hard.entries
|
||||
|| limits.references > hard.references
|
||||
|| limits.frames > hard.frames
|
||||
|| limits.numeric_units > hard.numeric_units
|
||||
{
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Source8 profile cannot exceed candidate hard limits.",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
limits,
|
||||
usage: Usage::default(),
|
||||
numeric: WorkMeter::new(
|
||||
WorkLimits::new(0, limits.numeric_units)
|
||||
.map_err(|e| error(Span::default(), e.to_string()))?,
|
||||
),
|
||||
failed: None,
|
||||
})
|
||||
}
|
||||
pub fn active(&self) -> Result<(), Error> {
|
||||
self.failed.clone().map_or(Ok(()), Err)
|
||||
}
|
||||
fn charge(
|
||||
&mut self,
|
||||
used: usize,
|
||||
amount: usize,
|
||||
maximum: usize,
|
||||
span: Span,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
if amount > maximum.saturating_sub(used) {
|
||||
let failure = error(span, format!("Source8 {name} limit exceeded."));
|
||||
self.failed = Some(failure.clone());
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn bytes(&mut self, amount: usize, span: Span) -> Result<(), Error> {
|
||||
self.charge(self.usage.bytes, amount, self.limits.bytes, span, "bytes")?;
|
||||
self.usage.bytes += amount;
|
||||
Ok(())
|
||||
}
|
||||
pub fn token(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(self.usage.tokens, 1, self.limits.tokens, span, "tokens")?;
|
||||
self.usage.tokens += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn entry(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(
|
||||
self.usage.entries,
|
||||
1,
|
||||
self.limits.entries,
|
||||
span,
|
||||
"syntax entries",
|
||||
)?;
|
||||
self.usage.entries += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn reference(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(
|
||||
self.usage.references,
|
||||
1,
|
||||
self.limits.references,
|
||||
span,
|
||||
"references",
|
||||
)?;
|
||||
self.usage.references += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn frame(&mut self, depth: usize, span: Span) -> Result<(), Error> {
|
||||
self.charge(0, depth, self.limits.frames, span, "syntax frames")?;
|
||||
self.usage.deepest_frame = self.usage.deepest_frame.max(depth);
|
||||
Ok(())
|
||||
}
|
||||
pub fn exact(&mut self, spelling: &str, span: Span) -> Result<Exact, Error> {
|
||||
self.active()?;
|
||||
match Exact::parse_literal(spelling, &mut self.numeric) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(cause) => {
|
||||
let failure = error(span, cause.to_string());
|
||||
self.failed = Some(failure.clone());
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn numeric_units(&self) -> u64 {
|
||||
self.numeric.usage().numeric_units
|
||||
}
|
||||
/// Check phase allocation bounds without recharging parsed syntax or exact
|
||||
/// work. S4 must also charge ordinary records through the inherited budget.
|
||||
pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
if count > self.limits.entries {
|
||||
return Err(error(span, "Source8 lowering entry limit exceeded."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Aggregate owned text for an index/update phase, checked before copying.
|
||||
pub fn index_bytes(&self, count: usize, span: Span) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
if count > self.limits.bytes {
|
||||
return Err(error(span, "Source8 index/output byte limit exceeded."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
// Parameter declarations are syntax entries, not reference uses.
|
||||
if count > self.limits.references.saturating_add(self.limits.entries) {
|
||||
return Err(error(span, "Source8 lowering occurrence limit exceeded."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! A borrowed significant-token cursor. Spans always refer to the full source.
|
||||
use super::*;
|
||||
use lex::{Class, Token};
|
||||
|
||||
pub(super) struct Cursor<'a, 'b> {
|
||||
pub source: &'a str,
|
||||
pub tokens: &'a [Token],
|
||||
pub cursor: usize,
|
||||
pub frames: usize,
|
||||
pub budget: &'b mut Budget,
|
||||
}
|
||||
impl<'a, 'b> Cursor<'a, 'b> {
|
||||
pub(super) fn new(source: &'a str, tokens: &'a [Token], budget: &'b mut Budget) -> Self {
|
||||
Self {
|
||||
source,
|
||||
tokens,
|
||||
cursor: 0,
|
||||
frames: 0,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Cursor<'_, '_> {
|
||||
pub(super) fn span(&self) -> Span {
|
||||
self.tokens.get(self.cursor).map_or(
|
||||
Span {
|
||||
start: self.source.len(),
|
||||
end: self.source.len(),
|
||||
},
|
||||
|token| token.span,
|
||||
)
|
||||
}
|
||||
pub(super) fn text(&self) -> &str {
|
||||
let span = self.span();
|
||||
&self.source[span.start..span.end]
|
||||
}
|
||||
pub(super) fn take(&mut self, expected: &str) -> Result<Span, Error> {
|
||||
if self.text() != expected {
|
||||
return Err(error(self.span(), format!("Expected '{expected}'.")));
|
||||
}
|
||||
let span = self.span();
|
||||
self.cursor += 1;
|
||||
Ok(span)
|
||||
}
|
||||
pub(super) fn end(&self) -> Result<(), Error> {
|
||||
if self.cursor != self.tokens.len() {
|
||||
Err(error(
|
||||
self.span(),
|
||||
"Unexpected content after analysis expression.",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(super) fn open(&mut self) -> Result<(), Error> {
|
||||
self.budget.frame(self.frames + 1, self.span())?;
|
||||
self.frames += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close(&mut self) {
|
||||
self.frames -= 1;
|
||||
}
|
||||
pub(super) fn reference(&mut self) -> Result<Reference, Error> {
|
||||
self.budget.reference(self.span())?;
|
||||
self.name()
|
||||
}
|
||||
pub(super) fn name(&mut self) -> Result<Reference, Error> {
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::Word)
|
||||
|| !crate::valid_alias(self.text())
|
||||
{
|
||||
return Err(error(span, "Expected a valid reference or parameter name."));
|
||||
}
|
||||
let name = self.text().to_owned();
|
||||
self.cursor += 1;
|
||||
Ok(Reference { name, span })
|
||||
}
|
||||
pub(super) fn quoted(&mut self) -> Result<String, Error> {
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::String)
|
||||
{
|
||||
return Err(error(span, "Expected a quoted string."));
|
||||
}
|
||||
let value =
|
||||
serde_json::from_str(self.text()).map_err(|_| error(span, "Invalid quoted string."))?;
|
||||
self.cursor += 1;
|
||||
Ok(value)
|
||||
}
|
||||
pub(super) fn last_end(&self) -> usize {
|
||||
self.tokens[self.cursor - 1].span.end
|
||||
}
|
||||
pub(super) fn exact_literal(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
|
||||
if self.text() == "ratio" {
|
||||
return self.ratio();
|
||||
}
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::Number)
|
||||
{
|
||||
return Err(error(span, "Expected an exact numeric atom or ratio."));
|
||||
}
|
||||
let value = self
|
||||
.budget
|
||||
.exact(&self.source[span.start..span.end], span)?;
|
||||
self.cursor += 1;
|
||||
Ok((
|
||||
value,
|
||||
LiteralSyntax {
|
||||
span,
|
||||
tokens: vec![span],
|
||||
},
|
||||
))
|
||||
}
|
||||
pub(super) fn integer(&mut self) -> Result<Span, Error> {
|
||||
let span = self.span();
|
||||
let text = self.text();
|
||||
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(error(span, "Expected unsigned integer digits."));
|
||||
}
|
||||
if text.len() > crate::analysis_numeric::MAX_COMPONENT_DIGITS {
|
||||
return Err(error(span, "Integer component exceeds digit limit."));
|
||||
}
|
||||
self.cursor += 1;
|
||||
Ok(span)
|
||||
}
|
||||
pub(super) fn ratio(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
|
||||
let first_token = self.cursor;
|
||||
let start = self.take("ratio")?.start;
|
||||
self.open()?;
|
||||
self.take("(")?;
|
||||
let negative = if self.text() == "-" {
|
||||
self.cursor += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let numerator = self.integer()?;
|
||||
self.take(",")?;
|
||||
let denominator = self.integer()?;
|
||||
let end = self.take(")")?.end;
|
||||
self.close();
|
||||
let n = &self.source[numerator.start..numerator.end];
|
||||
let d = &self.source[denominator.start..denominator.end];
|
||||
let length = 9 + usize::from(negative) + n.len() + d.len();
|
||||
if length > crate::analysis_numeric::MAX_LITERAL_BYTES {
|
||||
return Err(error(
|
||||
Span { start, end },
|
||||
"Numeric reconstruction exceeds literal byte limit.",
|
||||
));
|
||||
}
|
||||
let normalized = format!("ratio({}{n}, {d})", if negative { "-" } else { "" });
|
||||
Ok((
|
||||
self.budget.exact(&normalized, Span { start, end })?,
|
||||
LiteralSyntax {
|
||||
span: Span { start, end },
|
||||
tokens: self.tokens[first_token..self.cursor]
|
||||
.iter()
|
||||
.map(|token| token.span)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
//! Required typed declaration syntax. No model construction or resolution.
|
||||
use super::cursor::Cursor;
|
||||
use super::*;
|
||||
use crate::{Id, Layer};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeclarationKind {
|
||||
QuantityKind,
|
||||
Unit,
|
||||
Calculation,
|
||||
Property(Layer),
|
||||
Check(Layer),
|
||||
Case(Layer),
|
||||
}
|
||||
|
||||
/// Contextual recognition only: never add these words to the global alias ban.
|
||||
pub fn declaration_kind(word: &str) -> Option<DeclarationKind> {
|
||||
use DeclarationKind::*;
|
||||
Some(match word {
|
||||
"quantity_kind" => QuantityKind,
|
||||
"unit" => Unit,
|
||||
"calculation" => Calculation,
|
||||
"oa_property" => Property(Layer::Oa),
|
||||
"sa_property" => Property(Layer::Sa),
|
||||
"oa_check" => Check(Layer::Oa),
|
||||
"sa_check" => Check(Layer::Sa),
|
||||
"oa_analysis" => Case(Layer::Oa),
|
||||
"sa_analysis" => Case(Layer::Sa),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct IdentitySyntax {
|
||||
/// Missing IDs stay unresolved here. The bundle stabilization owner assigns
|
||||
/// and inserts an ID once, then reparses the actual returned bytes.
|
||||
pub value: Option<Id>,
|
||||
pub keyword: Option<Span>,
|
||||
pub literal: Option<Span>,
|
||||
pub insertion: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub kind: DeclarationKind,
|
||||
pub kind_span: Span,
|
||||
pub alias: Reference,
|
||||
pub name: String,
|
||||
pub name_span: Span,
|
||||
pub identity: IdentitySyntax,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Field<T> {
|
||||
/// Complete field including its keyword and trailing semicolon.
|
||||
pub span: Span,
|
||||
pub keyword: Span,
|
||||
/// Same meaning as an ordinary top-level field value span.
|
||||
pub value_span: Span,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ScalarSyntax {
|
||||
pub span: Span,
|
||||
pub kind: ScalarKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ScalarKind {
|
||||
Boolean,
|
||||
Number,
|
||||
Quantity(Reference),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct TypeSyntax {
|
||||
pub span: Span,
|
||||
pub scalar: ScalarSyntax,
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ParameterSyntax {
|
||||
pub span: Span,
|
||||
pub name: Reference,
|
||||
pub identity: IdentitySyntax,
|
||||
pub value_type: TypeSyntax,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct DimensionEntry {
|
||||
pub axis: Span,
|
||||
pub exponent: Span,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct DimensionSyntax {
|
||||
pub exponents: [i16; 7],
|
||||
pub entries: Vec<DimensionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AlgebraSyntax {
|
||||
Linear,
|
||||
Affine(Reference),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ExactSyntax {
|
||||
pub value: Exact,
|
||||
pub literal: LiteralSyntax,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct OverrideSyntax {
|
||||
pub span: Span,
|
||||
pub target: Reference,
|
||||
pub binding: Binding,
|
||||
pub binding_span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Body {
|
||||
QuantityKind {
|
||||
dimension: Field<DimensionSyntax>,
|
||||
algebra: Field<AlgebraSyntax>,
|
||||
},
|
||||
Unit {
|
||||
kind: Field<Reference>,
|
||||
scale: Field<ExactSyntax>,
|
||||
offset: Field<ExactSyntax>,
|
||||
symbol: Field<String>,
|
||||
},
|
||||
Calculation {
|
||||
parameters: Vec<ParameterSyntax>,
|
||||
result: Field<TypeSyntax>,
|
||||
body: Field<Binding>,
|
||||
},
|
||||
Property {
|
||||
subject: Field<Reference>,
|
||||
value_type: Field<TypeSyntax>,
|
||||
base: Field<Binding>,
|
||||
display_unit: Option<Field<Option<Reference>>>,
|
||||
},
|
||||
Check {
|
||||
subject: Option<Field<Option<Reference>>>,
|
||||
documented_constraint: Option<Field<Option<Reference>>>,
|
||||
predicate: Field<Binding>,
|
||||
},
|
||||
Case {
|
||||
subject: Option<Field<Option<Reference>>>,
|
||||
base: Field<()>,
|
||||
overrides: Field<Vec<OverrideSyntax>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Declaration {
|
||||
pub header: Header,
|
||||
pub span: Span,
|
||||
pub body_span: Span,
|
||||
pub description: Option<Field<String>>,
|
||||
/// Analysis roots may explicitly spell `parent none`; definitions may not.
|
||||
pub parent: Option<Field<()>>,
|
||||
pub body: Body,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
dimension: Option<Field<DimensionSyntax>>,
|
||||
algebra: Option<Field<AlgebraSyntax>>,
|
||||
kind: Option<Field<Reference>>,
|
||||
scale: Option<Field<ExactSyntax>>,
|
||||
offset: Option<Field<ExactSyntax>>,
|
||||
symbol: Option<Field<String>>,
|
||||
result: Option<Field<TypeSyntax>>,
|
||||
body: Option<Field<Binding>>,
|
||||
subject: Option<Field<Reference>>,
|
||||
optional_subject: Option<Field<Option<Reference>>>,
|
||||
value_type: Option<Field<TypeSyntax>>,
|
||||
base: Option<Field<Binding>>,
|
||||
display_unit: Option<Field<Option<Reference>>>,
|
||||
documented_constraint: Option<Field<Option<Reference>>>,
|
||||
predicate: Option<Field<Binding>>,
|
||||
baseline: Option<Field<()>>,
|
||||
overrides: Option<Field<Vec<OverrideSyntax>>>,
|
||||
}
|
||||
|
||||
fn required<T>(value: Option<T>, name: &str, at: Span) -> Result<T, Error> {
|
||||
value.ok_or_else(|| error(at, format!("Missing required field '{name}'.")))
|
||||
}
|
||||
|
||||
impl Fields {
|
||||
fn finish(
|
||||
self,
|
||||
kind: DeclarationKind,
|
||||
parameters: Vec<ParameterSyntax>,
|
||||
at: Span,
|
||||
) -> Result<Body, Error> {
|
||||
Ok(match kind {
|
||||
DeclarationKind::QuantityKind => Body::QuantityKind {
|
||||
dimension: required(self.dimension, "dimension", at)?,
|
||||
algebra: required(self.algebra, "algebra", at)?,
|
||||
},
|
||||
DeclarationKind::Unit => Body::Unit {
|
||||
kind: required(self.kind, "kind", at)?,
|
||||
scale: required(self.scale, "scale", at)?,
|
||||
offset: required(self.offset, "offset", at)?,
|
||||
symbol: required(self.symbol, "symbol", at)?,
|
||||
},
|
||||
DeclarationKind::Calculation => Body::Calculation {
|
||||
parameters,
|
||||
result: required(self.result, "result", at)?,
|
||||
body: required(self.body, "body", at)?,
|
||||
},
|
||||
DeclarationKind::Property(_) => Body::Property {
|
||||
subject: required(self.subject, "subject", at)?,
|
||||
value_type: required(self.value_type, "type", at)?,
|
||||
base: required(self.base, "base", at)?,
|
||||
display_unit: self.display_unit,
|
||||
},
|
||||
DeclarationKind::Check(_) => Body::Check {
|
||||
subject: self.optional_subject,
|
||||
documented_constraint: self.documented_constraint,
|
||||
predicate: required(self.predicate, "predicate", at)?,
|
||||
},
|
||||
DeclarationKind::Case(_) => Body::Case {
|
||||
subject: self.optional_subject,
|
||||
base: required(self.baseline, "base", at)?,
|
||||
overrides: required(self.overrides, "overrides", at)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume exactly one declaration from already charged full-file tokens.
|
||||
/// `version` is inherited from the caller's entry policy; fragments have no
|
||||
/// independent version header. Public parsing does not call this staged API.
|
||||
pub(super) fn parse(
|
||||
p: &mut Cursor<'_, '_>,
|
||||
version: u32,
|
||||
lexical_owner: Option<Id>,
|
||||
) -> Result<Declaration, Error> {
|
||||
p.budget.active()?;
|
||||
let kind_span = p.span();
|
||||
let kind = declaration_kind(p.text())
|
||||
.ok_or_else(|| error(kind_span, "Expected a short typed declaration word."))?;
|
||||
if version != 8 {
|
||||
return Err(error(kind_span, "Analysis declarations require source8."));
|
||||
}
|
||||
if lexical_owner.is_some() {
|
||||
return Err(error(
|
||||
kind_span,
|
||||
"Analysis declarations and definitions must be roots.",
|
||||
));
|
||||
}
|
||||
p.budget.entry(kind_span)?;
|
||||
p.cursor += 1;
|
||||
let alias = p.name()?;
|
||||
let name_span = p.span();
|
||||
let name = p.quoted()?;
|
||||
let identity = p.identity()?;
|
||||
p.open()?;
|
||||
let opening = p.take("{")?;
|
||||
let header = Header {
|
||||
kind,
|
||||
kind_span,
|
||||
alias,
|
||||
name,
|
||||
name_span,
|
||||
identity,
|
||||
span: Span {
|
||||
start: kind_span.start,
|
||||
end: opening.start,
|
||||
},
|
||||
};
|
||||
let mut fields = Fields::default();
|
||||
let mut description = None;
|
||||
let mut parent = None;
|
||||
let mut parameters = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut parameter_names = BTreeSet::new();
|
||||
let mut parameter_ids = BTreeSet::new();
|
||||
while p.text() != "}" {
|
||||
let keyword = p.span();
|
||||
p.budget.entry(keyword)?;
|
||||
if p.text() == "input" && kind == DeclarationKind::Calculation {
|
||||
let parameter = p.parameter()?;
|
||||
if !parameter_names.insert(parameter.name.name.clone()) {
|
||||
return Err(error(parameter.name.span, "Duplicate parameter name."));
|
||||
}
|
||||
if let Some(id) = parameter.identity.value
|
||||
&& !parameter_ids.insert(id)
|
||||
{
|
||||
return Err(error(
|
||||
parameter.identity.literal.expect("explicit identity"),
|
||||
"Duplicate parameter identity.",
|
||||
));
|
||||
}
|
||||
parameters.push(parameter);
|
||||
continue;
|
||||
}
|
||||
// Borrow the original spelling for duplicate detection; no field-name
|
||||
// allocation and no map insertion can overwrite an earlier value.
|
||||
let source = p.source;
|
||||
let key = &source[keyword.start..keyword.end];
|
||||
if !seen.insert(key) {
|
||||
return Err(error(keyword, format!("Duplicate field '{key}'.")));
|
||||
}
|
||||
p.cursor += 1;
|
||||
use DeclarationKind::*;
|
||||
match (kind, key) {
|
||||
(_, "description") => description = Some(p.field(keyword, |p| p.quoted())?),
|
||||
(Property(_) | Check(_) | Case(_), "parent") => {
|
||||
parent = Some(p.field(keyword, |p| {
|
||||
p.take("none")?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
(QuantityKind, "dimension") => {
|
||||
fields.dimension = Some(p.field(keyword, |p| p.dimension())?)
|
||||
}
|
||||
(QuantityKind, "algebra") => fields.algebra = Some(p.field(keyword, |p| p.algebra())?),
|
||||
(Unit, "kind") => fields.kind = Some(p.field(keyword, |p| p.at_reference())?),
|
||||
(Unit, "scale") => fields.scale = Some(p.field(keyword, |p| p.exact_value())?),
|
||||
(Unit, "offset") => fields.offset = Some(p.field(keyword, |p| p.exact_value())?),
|
||||
(Unit, "symbol") => fields.symbol = Some(p.field(keyword, |p| p.quoted())?),
|
||||
(Calculation, "result") => fields.result = Some(p.field(keyword, |p| p.value_type())?),
|
||||
(Calculation, "body") => fields.body = Some(p.field(keyword, syntax::binding)?),
|
||||
(Property(_), "subject") => {
|
||||
fields.subject = Some(p.field(keyword, |p| p.at_reference())?)
|
||||
}
|
||||
(Property(_), "type") => {
|
||||
fields.value_type = Some(p.field(keyword, |p| p.value_type())?)
|
||||
}
|
||||
(Property(_), "base") => fields.base = Some(p.field(keyword, syntax::binding)?),
|
||||
(Property(_), "display_unit") => {
|
||||
fields.display_unit = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_) | Case(_), "subject") => {
|
||||
fields.optional_subject = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_), "documented_constraint") => {
|
||||
fields.documented_constraint = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_), "predicate") => fields.predicate = Some(p.field(keyword, syntax::binding)?),
|
||||
(Case(_), "base") => {
|
||||
fields.baseline = Some(p.field(keyword, |p| {
|
||||
p.take("ModelBaseline")?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
(Case(_), "overrides") => fields.overrides = Some(p.field(keyword, |p| p.overrides())?),
|
||||
_ => {
|
||||
return Err(error(
|
||||
keyword,
|
||||
format!("Unknown field '{key}' for this typed declaration."),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let closing = p.take("}")?;
|
||||
p.close();
|
||||
let body = fields.finish(kind, parameters, closing)?;
|
||||
Ok(Declaration {
|
||||
header,
|
||||
span: Span {
|
||||
start: kind_span.start,
|
||||
end: closing.end,
|
||||
},
|
||||
body_span: Span {
|
||||
start: opening.end,
|
||||
end: closing.start,
|
||||
},
|
||||
description,
|
||||
parent,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
fn field<T>(
|
||||
&mut self,
|
||||
keyword: Span,
|
||||
parse: impl FnOnce(&mut Self) -> Result<T, Error>,
|
||||
) -> Result<Field<T>, Error> {
|
||||
let start = self.span().start;
|
||||
let value = parse(self)?;
|
||||
let end = self.last_end();
|
||||
let terminator = self.take(";")?;
|
||||
Ok(Field {
|
||||
span: Span {
|
||||
start: keyword.start,
|
||||
end: terminator.end,
|
||||
},
|
||||
keyword,
|
||||
value_span: Span { start, end },
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn identity(&mut self) -> Result<IdentitySyntax, Error> {
|
||||
if self.text() != "id" {
|
||||
return Ok(IdentitySyntax {
|
||||
value: None,
|
||||
keyword: None,
|
||||
literal: None,
|
||||
insertion: Some(self.span().start),
|
||||
});
|
||||
}
|
||||
let keyword = self.take("id")?;
|
||||
let literal = self.span();
|
||||
let spelling = self.quoted()?;
|
||||
if spelling.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|at| spelling.as_bytes()[at] == b'-')
|
||||
{
|
||||
return Err(error(
|
||||
literal,
|
||||
"Expected UUID in 8-4-4-4-12 hexadecimal form.",
|
||||
));
|
||||
}
|
||||
let id = Id::parse_str(&spelling).map_err(|_| error(literal, "Invalid UUID identity."))?;
|
||||
Ok(IdentitySyntax {
|
||||
value: Some(id),
|
||||
keyword: Some(keyword),
|
||||
literal: Some(literal),
|
||||
insertion: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parameter(&mut self) -> Result<ParameterSyntax, Error> {
|
||||
let start = self.take("input")?.start;
|
||||
let name = self.name()?;
|
||||
let identity = self.identity()?;
|
||||
self.take(":")?;
|
||||
let value_type = self.value_type()?;
|
||||
let end = self.take(";")?.end;
|
||||
Ok(ParameterSyntax {
|
||||
span: Span { start, end },
|
||||
name,
|
||||
identity,
|
||||
value_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn at_reference(&mut self) -> Result<Reference, Error> {
|
||||
self.take("@")?;
|
||||
self.reference()
|
||||
}
|
||||
|
||||
fn optional_reference(&mut self) -> Result<Option<Reference>, Error> {
|
||||
if self.text() == "none" {
|
||||
self.cursor += 1;
|
||||
Ok(None)
|
||||
} else {
|
||||
self.at_reference().map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_value(&mut self) -> Result<ExactSyntax, Error> {
|
||||
let (value, literal) = self.exact_literal()?;
|
||||
Ok(ExactSyntax { value, literal })
|
||||
}
|
||||
|
||||
fn value_type(&mut self) -> Result<TypeSyntax, Error> {
|
||||
let start = self.span().start;
|
||||
self.budget.entry(self.span())?;
|
||||
let list = self.text() == "List";
|
||||
if list {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("(")?;
|
||||
}
|
||||
let scalar = self.scalar_type()?;
|
||||
if list {
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
}
|
||||
Ok(TypeSyntax {
|
||||
span: Span {
|
||||
start,
|
||||
end: self.last_end(),
|
||||
},
|
||||
scalar,
|
||||
list,
|
||||
})
|
||||
}
|
||||
|
||||
fn scalar_type(&mut self) -> Result<ScalarSyntax, Error> {
|
||||
let start = self.span().start;
|
||||
let kind = match self.text() {
|
||||
"Boolean" => {
|
||||
self.cursor += 1;
|
||||
ScalarKind::Boolean
|
||||
}
|
||||
"Number" => {
|
||||
self.cursor += 1;
|
||||
ScalarKind::Number
|
||||
}
|
||||
"Quantity" => {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("(")?;
|
||||
let reference = self.at_reference()?;
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
ScalarKind::Quantity(reference)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
self.span(),
|
||||
"Expected Boolean, Number or Quantity(@kind); nested List is not a scalar type.",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(ScalarSyntax {
|
||||
span: Span {
|
||||
start,
|
||||
end: self.last_end(),
|
||||
},
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn dimension(&mut self) -> Result<DimensionSyntax, Error> {
|
||||
self.open()?;
|
||||
self.take("{")?;
|
||||
let mut exponents = [0; 7];
|
||||
let mut seen = [false; 7];
|
||||
let mut entries = Vec::new();
|
||||
while self.text() != "}" {
|
||||
let axis = self.span();
|
||||
self.budget.entry(axis)?;
|
||||
let index = match self.text() {
|
||||
"length" => 0,
|
||||
"mass" => 1,
|
||||
"time" => 2,
|
||||
"electric_current" => 3,
|
||||
"temperature" => 4,
|
||||
"amount" => 5,
|
||||
"luminous_intensity" => 6,
|
||||
_ => return Err(error(axis, "Unknown dimension axis.")),
|
||||
};
|
||||
if seen[index] {
|
||||
return Err(error(axis, "Duplicate dimension axis."));
|
||||
}
|
||||
seen[index] = true;
|
||||
self.cursor += 1;
|
||||
let start = self.span().start;
|
||||
let negative = self.text() == "-";
|
||||
if negative {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let digits = self.integer()?;
|
||||
let value = self.source[digits.start..digits.end]
|
||||
.parse::<i16>()
|
||||
.map_err(|_| error(digits, "Dimension exponent exceeds -32..32."))?;
|
||||
if value > 32 {
|
||||
return Err(error(digits, "Dimension exponent exceeds -32..32."));
|
||||
}
|
||||
exponents[index] = if negative { -value } else { value };
|
||||
let exponent = Span {
|
||||
start,
|
||||
end: digits.end,
|
||||
};
|
||||
let end = self.take(";")?.end;
|
||||
entries.push(DimensionEntry {
|
||||
axis,
|
||||
exponent,
|
||||
span: Span {
|
||||
start: axis.start,
|
||||
end,
|
||||
},
|
||||
});
|
||||
}
|
||||
self.take("}")?;
|
||||
self.close();
|
||||
Ok(DimensionSyntax { exponents, entries })
|
||||
}
|
||||
|
||||
fn algebra(&mut self) -> Result<AlgebraSyntax, Error> {
|
||||
if self.text() == "Linear" {
|
||||
self.cursor += 1;
|
||||
return Ok(AlgebraSyntax::Linear);
|
||||
}
|
||||
self.take("Affine")?;
|
||||
self.open()?;
|
||||
self.take("(")?;
|
||||
let difference = self.at_reference()?;
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
Ok(AlgebraSyntax::Affine(difference))
|
||||
}
|
||||
|
||||
fn overrides(&mut self) -> Result<Vec<OverrideSyntax>, Error> {
|
||||
self.open()?;
|
||||
self.take("{")?;
|
||||
let mut entries = Vec::new();
|
||||
let mut names = BTreeSet::new();
|
||||
while self.text() != "}" {
|
||||
let start = self.span().start;
|
||||
self.budget.entry(self.span())?;
|
||||
let target = self.at_reference()?;
|
||||
if !names.insert(target.name.clone()) {
|
||||
return Err(error(target.span, "Duplicate override target."));
|
||||
}
|
||||
self.take("=")?;
|
||||
let binding_start = self.span().start;
|
||||
let binding = syntax::binding(self)?;
|
||||
let binding_span = Span {
|
||||
start: binding_start,
|
||||
end: self.last_end(),
|
||||
};
|
||||
let end = self.take(";")?.end;
|
||||
entries.push(OverrideSyntax {
|
||||
span: Span { start, end },
|
||||
target,
|
||||
binding,
|
||||
binding_span,
|
||||
});
|
||||
}
|
||||
self.take("}")?;
|
||||
self.close();
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,613 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use crate::language::analysis_source::budget::Limits;
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn budget() -> Budget {
|
||||
Budget::new(limits()).unwrap()
|
||||
}
|
||||
|
||||
fn declaration(source: &str) -> Declaration {
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut budget);
|
||||
let result = parse(&mut cursor, 8, None).unwrap();
|
||||
cursor.end().unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
fn failure(source: &str) -> Error {
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
parse(&mut Cursor::new(source, &tokens, &mut budget), 8, None).unwrap_err()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_declarations_have_independent_typed_body_and_header_oracles() {
|
||||
let source = "quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" { dimension { time 1; length -2; }; algebra Affine(@difference); description \"雪\"; }";
|
||||
let actual = declaration(source);
|
||||
assert_eq!(actual.header.kind, DeclarationKind::QuantityKind);
|
||||
assert_eq!(actual.header.alias.name, "duration");
|
||||
assert_eq!(actual.header.name, "Duration");
|
||||
assert_eq!(
|
||||
actual.header.identity.value,
|
||||
Some(Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap())
|
||||
);
|
||||
assert_eq!(actual.description.as_ref().unwrap().value, "雪");
|
||||
assert!(actual.parent.is_none());
|
||||
let Body::QuantityKind { dimension, algebra } = actual.body else {
|
||||
panic!("quantity kind")
|
||||
};
|
||||
assert_eq!(dimension.value.exponents, [-2, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(text(source, dimension.keyword), "dimension");
|
||||
assert_eq!(text(source, dimension.value_span), "{ time 1; length -2; }");
|
||||
assert_eq!(text(source, dimension.value.entries[1].exponent), "-2");
|
||||
let AlgebraSyntax::Affine(difference) = algebra.value else {
|
||||
panic!("affine")
|
||||
};
|
||||
assert_eq!(difference.name, "difference");
|
||||
|
||||
let Body::Unit { kind, scale, offset, symbol } = declaration("unit millisecond \"Millisecond\" { kind @duration; scale ratio (1, // divisor\n 1000); offset 0; symbol \"ms\"; }").body else { panic!("unit") };
|
||||
assert_eq!(kind.value.name, "duration");
|
||||
assert_eq!(scale.value.value.canonical_literal(), "ratio(1, 1000)");
|
||||
assert_eq!(offset.value.value.canonical_literal(), "0");
|
||||
assert_eq!(symbol.value, "ms");
|
||||
|
||||
let Body::Calculation { parameters, result, body } = declaration("calculation combine \"Combine durations\" { input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration); input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration); result Quantity(@duration); body $a + $b; }").body else { panic!("calculation") };
|
||||
assert_eq!(
|
||||
parameters
|
||||
.iter()
|
||||
.map(|p| p.name.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a", "b"]
|
||||
);
|
||||
assert!(!result.value.list);
|
||||
assert!(
|
||||
matches!(result.value.scalar.kind, ScalarKind::Quantity(ref r) if r.name == "duration")
|
||||
);
|
||||
let Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary { op, left, right },
|
||||
..
|
||||
}) = body.value
|
||||
else {
|
||||
panic!("binary")
|
||||
};
|
||||
assert_eq!(op, BinaryOp::Add);
|
||||
assert!(matches!(left.kind, ExpressionKind::Parameter(ref r) if r.name == "a"));
|
||||
assert!(matches!(right.kind, ExpressionKind::Parameter(ref r) if r.name == "b"));
|
||||
|
||||
for (word, layer) in [("oa_property", Layer::Oa), ("sa_property", Layer::Sa)] {
|
||||
let actual = declaration(&format!(
|
||||
"{word} receive_time \"Receive time\" {{ subject @receive; type Quantity(@duration); base 750 [@millisecond]; display_unit @millisecond; parent none; }}"
|
||||
));
|
||||
assert_eq!(actual.header.kind, DeclarationKind::Property(layer));
|
||||
assert!(actual.parent.is_some());
|
||||
let Body::Property {
|
||||
subject,
|
||||
value_type,
|
||||
base,
|
||||
display_unit,
|
||||
} = actual.body
|
||||
else {
|
||||
panic!("property")
|
||||
};
|
||||
assert_eq!(subject.value.name, "receive");
|
||||
assert!(matches!(
|
||||
value_type.value.scalar.kind,
|
||||
ScalarKind::Quantity(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
base.value,
|
||||
Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Quantity { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(display_unit.unwrap().value.unwrap().name, "millisecond");
|
||||
}
|
||||
for word in ["oa_check", "sa_check"] {
|
||||
let Body::Check { subject, documented_constraint, predicate } = declaration(&format!("{word} budget \"Response budget\" {{ subject none; documented_constraint @constraint; predicate @total_time <= @limit; }}")).body else { panic!("check") };
|
||||
assert!(subject.unwrap().value.is_none());
|
||||
assert_eq!(
|
||||
documented_constraint.unwrap().value.unwrap().name,
|
||||
"constraint"
|
||||
);
|
||||
assert!(matches!(
|
||||
predicate.value,
|
||||
Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary {
|
||||
op: BinaryOp::LessOrEqual,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
for word in ["oa_analysis", "sa_analysis"] {
|
||||
let Body::Case { subject, base, overrides } = declaration(&format!("{word} degraded \"Degraded radio\" {{ subject @respond; base ModelBaseline; overrides {{ @receive_time = unbound; }}; }}")).body else { panic!("case") };
|
||||
assert_eq!(subject.unwrap().value.unwrap().name, "respond");
|
||||
assert!(base.value_span.end > base.value_span.start);
|
||||
assert_eq!(overrides.value.len(), 1);
|
||||
assert_eq!(overrides.value[0].target.name, "receive_time");
|
||||
assert!(matches!(overrides.value[0].binding, Binding::Unbound(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_unbound_and_all_scalar_list_types_are_explicit_valid_syntax() {
|
||||
for (spelling, list, kind) in [
|
||||
("Boolean", false, 0),
|
||||
("Number", false, 1),
|
||||
("Quantity(@duration)", false, 2),
|
||||
("List(Boolean)", true, 0),
|
||||
("List(Number)", true, 1),
|
||||
("List(Quantity(@duration))", true, 2),
|
||||
] {
|
||||
let Body::Calculation {
|
||||
parameters,
|
||||
result,
|
||||
body,
|
||||
} = declaration(&format!(
|
||||
"calculation c \"C\" {{ result {spelling}; body unbound; }}"
|
||||
))
|
||||
.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(parameters.is_empty());
|
||||
assert_eq!(result.value.list, list);
|
||||
assert_eq!(
|
||||
match result.value.scalar.kind {
|
||||
ScalarKind::Boolean => 0,
|
||||
ScalarKind::Number => 1,
|
||||
ScalarKind::Quantity(_) => 2,
|
||||
},
|
||||
kind
|
||||
);
|
||||
assert!(matches!(body.value, Binding::Unbound(_)));
|
||||
}
|
||||
let Body::QuantityKind { dimension, algebra } =
|
||||
declaration("quantity_kind q \"Q\" { dimension {}; algebra Linear; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(dimension.value.exponents, [0; 7]);
|
||||
assert!(dimension.value.entries.is_empty());
|
||||
assert_eq!(algebra.value, AlgebraSyntax::Linear);
|
||||
let Body::Case {
|
||||
subject, overrides, ..
|
||||
} = declaration("oa_analysis c \"C\" { base ModelBaseline; overrides {}; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(subject.is_none());
|
||||
assert!(overrides.value.is_empty());
|
||||
let Body::Check {
|
||||
subject,
|
||||
documented_constraint,
|
||||
..
|
||||
} = declaration("oa_check c \"C\" { predicate unbound; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(subject.is_none() && documented_constraint.is_none());
|
||||
let Body::Property { display_unit, .. } =
|
||||
declaration("oa_property p \"P\" { subject @s; type Number; base unbound; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(display_unit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_fields_and_unknown_or_duplicate_entries_reject_locally() {
|
||||
let required_sets: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"quantity_kind q \"Q\"",
|
||||
&["dimension {};", "algebra Linear;"],
|
||||
),
|
||||
(
|
||||
"unit u \"U\"",
|
||||
&["kind @q;", "scale 1;", "offset 0;", "symbol \"u\";"],
|
||||
),
|
||||
("calculation c \"C\"", &["result Number;", "body unbound;"]),
|
||||
(
|
||||
"oa_property p \"P\"",
|
||||
&["subject @s;", "type Number;", "base unbound;"],
|
||||
),
|
||||
("oa_check c \"C\"", &["predicate unbound;"]),
|
||||
(
|
||||
"oa_analysis a \"A\"",
|
||||
&["base ModelBaseline;", "overrides {};"],
|
||||
),
|
||||
];
|
||||
for (header, fields) in required_sets {
|
||||
declaration(&format!("{header} {{ {} }}", fields.join(" ")));
|
||||
for removed in 0..fields.len() {
|
||||
let remaining = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, field)| (i != removed).then_some(*field))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let error = failure(&format!("{header} {{ {remaining} }}"));
|
||||
assert!(
|
||||
error.message.contains("Missing required field"),
|
||||
"{error:?}"
|
||||
);
|
||||
let duplicate = format!("{header} {{ {} {} }}", fields.join(" "), fields[removed]);
|
||||
assert!(failure(&duplicate).message.contains("Duplicate field"));
|
||||
}
|
||||
}
|
||||
for source in [
|
||||
"quantity_kind q \"Q\" { dimension { time 1; time 2; }; algebra Linear; }",
|
||||
"quantity_kind q \"Q\" { dimension { unknown 1; }; algebra Linear; }",
|
||||
"quantity_kind q \"Q\" { dimension { time 33; }; algebra Linear; }",
|
||||
"unit u \"U\" { kind @q; scale 1 + 2; offset 0; symbol \"u\"; }",
|
||||
"unit u \"U\" { kind @q; scale 1 [@u]; offset 0; symbol \"u\"; }",
|
||||
"unit u \"U\" { kind @q; scale ratio(1, 0); offset 0; symbol \"u\"; }",
|
||||
"calculation c \"C\" { result List(List(Number)); body unbound; }",
|
||||
"calculation c \"C\" { input x : Number; input x : Number; result Number; body unbound; }",
|
||||
"calculation c \"C\" { input x id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; input y id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; result Number; body unbound; }",
|
||||
"oa_property p \"P\" { subject none; type Number; base unbound; }",
|
||||
"oa_property p \"P\" { subject @s; type Number; base none; }",
|
||||
"oa_property p \"P\" { subject @s; type Number; base unbound; parent @s; }",
|
||||
"oa_analysis a \"A\" { base @another; overrides {}; }",
|
||||
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = clear; }; }",
|
||||
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }",
|
||||
"oa_check c \"C\" { predicate @f(x: 1, x: 2); }",
|
||||
"quantity_kind q \"Q\" { dimension {}; algebra Linear; parent none; }",
|
||||
"oa_check c \"C\" { predicate unbound; invented none; }",
|
||||
] {
|
||||
failure(source);
|
||||
}
|
||||
let duplicate = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }";
|
||||
let error = failure(duplicate);
|
||||
assert_eq!(text(duplicate, error.span), "p");
|
||||
assert!(error.span.start > duplicate.find("@p").unwrap() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_cursor_preserves_full_file_spans_and_does_not_rescan_fields() {
|
||||
let source = "// 雪\r\ncalculation c \"C\" { input a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a); }\r\nunit u \"U\" { kind @q; scale 1; offset 0; symbol \"u\"; }";
|
||||
let mut shared = budget();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let lexical = shared.usage;
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut shared);
|
||||
let first = parse(&mut cursor, 8, None).unwrap();
|
||||
assert_eq!(cursor.text(), "unit");
|
||||
let first_end = first.span.end;
|
||||
let second = parse(&mut cursor, 8, None).unwrap();
|
||||
cursor.end().unwrap();
|
||||
assert!(second.span.start > first_end);
|
||||
assert_eq!(text(source, first.header.kind_span), "calculation");
|
||||
assert_eq!(text(source, first.header.name_span), "\"C\"");
|
||||
assert_eq!(text(source, second.header.alias.span), "u");
|
||||
assert_eq!(
|
||||
text(source, first.body_span).trim().strip_prefix("input"),
|
||||
Some(
|
||||
" a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a);"
|
||||
)
|
||||
);
|
||||
let Body::Calculation {
|
||||
parameters, body, ..
|
||||
} = first.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(text(source, parameters[0].span), "input a : Number;");
|
||||
assert_eq!(text(source, parameters[0].value_type.span), "Number");
|
||||
let Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary { left, right, .. },
|
||||
..
|
||||
}) = body.value
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
arguments: left, ..
|
||||
} = left.kind
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
arguments: right, ..
|
||||
} = right.kind
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let spans = [
|
||||
left[0].parameter.span,
|
||||
left[1].parameter.span,
|
||||
right[0].parameter.span,
|
||||
right[1].parameter.span,
|
||||
];
|
||||
assert_eq!(spans.map(|span| text(source, span)), ["a", "b", "a", "b"]);
|
||||
assert!(spans.windows(2).all(|pair| pair[0].end < pair[1].start));
|
||||
let literal = left[0].value.literal.as_ref().unwrap();
|
||||
assert_eq!(text(source, literal.span), "ratio (1, // first\r\n 2)");
|
||||
assert_eq!(
|
||||
literal
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|span| text(source, *span))
|
||||
.collect::<Vec<_>>(),
|
||||
["ratio", "(", "1", ",", "2", ")"]
|
||||
);
|
||||
assert_eq!(shared.usage.bytes, source.len());
|
||||
assert_eq!(shared.usage.bytes, lexical.bytes);
|
||||
assert_eq!(shared.usage.tokens, lexical.tokens);
|
||||
assert_eq!(shared.usage.references, 9); // two calls, four keys, two $a, unit kind.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_metadata_is_stable_and_parameter_ids_are_owner_scoped() {
|
||||
let source =
|
||||
"calculation c \"C\" { input a // identity here\r\n : Number; result Number; body $a; }";
|
||||
let first = declaration(source);
|
||||
let second = declaration(source);
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
first.header.identity.insertion,
|
||||
Some(source.find('{').unwrap())
|
||||
);
|
||||
let Body::Calculation { parameters, .. } = first.body else {
|
||||
panic!()
|
||||
};
|
||||
let id = ¶meters[0].identity;
|
||||
assert!(id.value.is_none() && id.literal.is_none() && id.keyword.is_none());
|
||||
assert_eq!(id.insertion, Some(source.find(':').unwrap()));
|
||||
let explicit = "28ff40b0-1b8f-4735-8536-5a6ab21b56cb";
|
||||
// Syntax does not add ParameterIds to a global identity registry, and an
|
||||
// explicit parameter ID can equal its own calculation's global UUID.
|
||||
for name in ["one", "two"] {
|
||||
let source = format!(
|
||||
"calculation {name} \"C\" id \"{explicit}\" {{ input a id \"{explicit}\" : Number; result Number; body $a; }}"
|
||||
);
|
||||
let actual = declaration(&source);
|
||||
let Body::Calculation { parameters, .. } = actual.body else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(parameters[0].identity.value, actual.header.identity.value);
|
||||
assert!(parameters[0].identity.insertion.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_version_root_and_short_word_contracts_do_not_enable_public8() {
|
||||
let source = "oa_check c \"C\" { predicate unbound; }";
|
||||
for version in [1, 2, 3, 4, 5, 6, 7, 9] {
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
let error = parse(
|
||||
&mut Cursor::new(source, &tokens, &mut budget),
|
||||
version,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(text(source, error.span), "oa_check");
|
||||
assert!(error.message.contains("source8"));
|
||||
}
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
assert!(
|
||||
parse(
|
||||
&mut Cursor::new(source, &tokens, &mut budget),
|
||||
8,
|
||||
Some(Id::nil())
|
||||
)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("roots")
|
||||
);
|
||||
for word in [
|
||||
"oa_engineering_property",
|
||||
"sa_engineering_property",
|
||||
"oa_analysis_check",
|
||||
"sa_analysis_check",
|
||||
"oa_analysis_case",
|
||||
"sa_analysis_case",
|
||||
] {
|
||||
assert!(declaration_kind(word).is_none());
|
||||
}
|
||||
for alias in [
|
||||
"unit",
|
||||
"calculation",
|
||||
"quantity_kind",
|
||||
"oa_property",
|
||||
"if",
|
||||
"and",
|
||||
"List",
|
||||
] {
|
||||
let old = format!("arcadia 7; project {alias} \"P\" {{}}");
|
||||
assert!(crate::language::parse(&old).is_ok(), "{old}");
|
||||
}
|
||||
let contextual =
|
||||
declaration("calculation unit \"C\" { input and : Number; result Number; body $and; }");
|
||||
assert_eq!(contextual.header.alias.name, "unit");
|
||||
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
|
||||
for version in 3..=7 {
|
||||
assert!(
|
||||
crate::language::parse(&format!(
|
||||
"arcadia {version}; project p \"P\" {{ {source} }}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_budgets_share_literals_types_references_and_failure_state() {
|
||||
let source = "unit u \"U\" { kind @q; scale ratio(1, 3); offset ratio(2, 7); symbol \"u\"; }";
|
||||
let mut measured = budget();
|
||||
let tokens = lex::tokens(source, &mut measured).unwrap();
|
||||
parse(&mut Cursor::new(source, &tokens, &mut measured), 8, None).unwrap();
|
||||
let numeric = measured.numeric_units();
|
||||
assert!(numeric > 0);
|
||||
// All declaration, field and type entries share the same preallocation
|
||||
// ceiling. Measure a deterministic valid counterpart before reducing it.
|
||||
for (maximum, succeeds) in [
|
||||
(measured.usage.entries, true),
|
||||
(measured.usage.entries - 1, false),
|
||||
] {
|
||||
let mut profile = limits();
|
||||
profile.entries = maximum;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
|
||||
assert_eq!(result.is_ok(), succeeds);
|
||||
if !succeeds {
|
||||
assert!(result.unwrap_err().message.contains("entries"));
|
||||
}
|
||||
}
|
||||
for (maximum, succeeds) in [(numeric, true), (numeric - 1, false)] {
|
||||
let mut profile = limits();
|
||||
profile.numeric_units = maximum;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
|
||||
assert_eq!(result.is_ok(), succeeds);
|
||||
}
|
||||
let first =
|
||||
"calculation c \"C\" { input x : Quantity(@q); result Quantity(@q); body unbound; }";
|
||||
let second = "oa_property p \"P\" { subject @s; type Quantity(@q); base 1 [@u]; }";
|
||||
let mut profile = limits();
|
||||
profile.references = 4;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(first, &mut shared).unwrap();
|
||||
parse(&mut Cursor::new(first, &tokens, &mut shared), 8, None).unwrap();
|
||||
assert_eq!(shared.usage.references, 2);
|
||||
let tokens = lex::tokens(second, &mut shared).unwrap();
|
||||
let error = parse(&mut Cursor::new(second, &tokens, &mut shared), 8, None).unwrap_err();
|
||||
assert_eq!(text(second, error.span), "u");
|
||||
assert_eq!(shared.usage.references, 4);
|
||||
assert_eq!(shared.active().unwrap_err(), error);
|
||||
let numeric_after_failure = shared.numeric_units();
|
||||
assert!(lex::tokens("1", &mut shared).is_err());
|
||||
assert_eq!(shared.numeric_units(), numeric_after_failure);
|
||||
let unbound_tokens = lex::tokens("unbound", &mut budget()).unwrap();
|
||||
assert_eq!(
|
||||
syntax::binding(&mut Cursor::new("unbound", &unbound_tokens, &mut shared)).unwrap_err(),
|
||||
error
|
||||
);
|
||||
assert!(
|
||||
declaration("oa_check fresh \"Fresh\" { predicate true; }")
|
||||
.parent
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_frame_budget_and_truncated_declaration_cleanup_have_nearby_successes() {
|
||||
let source = "calculation c \"C\" { result List(Quantity(@q)); body unbound; }";
|
||||
for (maximum, succeeds) in [(3, true), (2, false)] {
|
||||
let mut profile = limits();
|
||||
profile.frames = maximum;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
assert_eq!(
|
||||
parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None).is_ok(),
|
||||
succeeds
|
||||
);
|
||||
}
|
||||
let source = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = if(true, ratio(1, 2), @f(a: 1)); }; }";
|
||||
let tokens = lex::tokens(source, &mut budget()).unwrap();
|
||||
for token in tokens
|
||||
.iter()
|
||||
.filter(|token| matches!(text(source, token.span), "{" | "(" | "," | ":" | "="))
|
||||
{
|
||||
let prefix = &source[..token.span.end];
|
||||
let mut shared = budget();
|
||||
let tokens = lex::tokens(prefix, &mut shared).unwrap();
|
||||
assert!(parse(&mut Cursor::new(prefix, &tokens, &mut shared), 8, None).is_err());
|
||||
declaration("oa_check fresh \"Fresh\" { predicate true; }");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_brace_frames_have_exact_zero_one_two_boundaries() {
|
||||
let mut profile = limits();
|
||||
profile.frames = 0;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens("unbound", &mut shared).unwrap();
|
||||
let mut cursor = Cursor::new("unbound", &tokens, &mut shared);
|
||||
assert!(matches!(
|
||||
syntax::binding(&mut cursor).unwrap(),
|
||||
Binding::Unbound(_)
|
||||
));
|
||||
assert_eq!(cursor.frames, 0);
|
||||
assert_eq!(shared.usage.deepest_frame, 0);
|
||||
|
||||
for (source, needed) in [
|
||||
("calculation c \"C\" { result Number; body unbound; }", 1),
|
||||
("quantity_kind q \"Q\" { dimension {}; algebra Linear; }", 2),
|
||||
(
|
||||
"oa_analysis a \"A\" { base ModelBaseline; overrides {}; }",
|
||||
2,
|
||||
),
|
||||
] {
|
||||
for maximum in 0..=2 {
|
||||
let mut profile = limits();
|
||||
profile.frames = maximum;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut shared);
|
||||
let result = parse(&mut cursor, 8, None);
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
maximum >= needed,
|
||||
"{source}, limit {maximum}"
|
||||
);
|
||||
if let Err(error) = result {
|
||||
assert!(error.message.contains("frames"));
|
||||
assert_eq!(text(source, error.span), "{");
|
||||
assert_eq!(
|
||||
error.span.start,
|
||||
if maximum == 0 {
|
||||
source.find('{').unwrap()
|
||||
} else {
|
||||
source.rfind('{').unwrap()
|
||||
}
|
||||
);
|
||||
} else {
|
||||
assert_eq!(cursor.frames, 0);
|
||||
cursor.end().unwrap();
|
||||
assert_eq!(shared.usage.deepest_frame, needed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_frames_restore_the_callers_inherited_context() {
|
||||
let source = "calculation a \"A\" { result Number; body unbound; } calculation b \"B\" { result Number; body unbound; }";
|
||||
for maximum in [1, 2] {
|
||||
let mut profile = limits();
|
||||
profile.frames = maximum;
|
||||
let mut shared = Budget::new(profile).unwrap();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut shared);
|
||||
// Future project/include integration owns this outer context. The
|
||||
// declaration consumer must add to it, then restore it after closing.
|
||||
cursor.frames = 1;
|
||||
let first = parse(&mut cursor, 8, None);
|
||||
if maximum == 1 {
|
||||
let error = first.unwrap_err();
|
||||
assert!(error.message.contains("frames"));
|
||||
assert_eq!(text(source, error.span), "{");
|
||||
} else {
|
||||
assert!(first.is_ok());
|
||||
assert_eq!(cursor.frames, 1);
|
||||
parse(&mut cursor, 8, None).unwrap();
|
||||
assert_eq!(cursor.frames, 1);
|
||||
cursor.end().unwrap();
|
||||
assert_eq!(shared.usage.deepest_frame, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Temporary typed source witnesses. No arena index becomes a model identity.
|
||||
//! The staged entry point parses each supplied typed file once. A future bundle
|
||||
//! parser must retain the same opaque parse/lower provenance. Occurrence
|
||||
//! completeness trusts that lowerer; the private builder does not prove the
|
||||
//! presence of every syntax reference in a caller-fabricated Lowered value.
|
||||
use super::cursor::Cursor;
|
||||
use super::lower::{self, Child, Field, Lowered, Role, Target, Unit};
|
||||
use super::{Budget, Error, Span, declarations, error, lex};
|
||||
use crate::{Id, Project, analysis as a};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Persistent structural path: cloning shares ancestors, and equality/order
|
||||
/// compare the complete edge sequence, never allocation or arena identities.
|
||||
/// Depth is checked against the inherited frame limit before allocation.
|
||||
/// Comparison and Debug walk leaf-to-root without allocating or recursing;
|
||||
/// this private total order need not be root-first lexicographic order.
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Path(Option<Arc<Step>>);
|
||||
struct Step {
|
||||
parent: Path,
|
||||
edge: Child,
|
||||
}
|
||||
impl PartialEq for Path {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for Path {}
|
||||
impl PartialOrd for Path {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for Path {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let (mut left, mut right) = (self, other);
|
||||
loop {
|
||||
match (left.0.as_deref(), right.0.as_deref()) {
|
||||
(Some(a), Some(b)) => {
|
||||
let order = a.edge.cmp(&b.edge);
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
(left, right) = (&a.parent, &b.parent);
|
||||
}
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for Path {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Debug intentionally shows the same leaf-to-root order as comparison.
|
||||
let mut list = formatter.debug_list();
|
||||
let mut path = self;
|
||||
while let Some(step) = &path.0 {
|
||||
list.entry(&step.edge);
|
||||
path = &step.parent;
|
||||
}
|
||||
list.finish()
|
||||
}
|
||||
}
|
||||
impl Drop for Path {
|
||||
fn drop(&mut self) {
|
||||
let mut next = self.0.take();
|
||||
while let Some(link) = next {
|
||||
// Shared ancestors remain owned elsewhere. The final owner peels
|
||||
// unique links here, taking the parent before Step is dropped.
|
||||
let Some(mut step) = Arc::into_inner(link) else {
|
||||
break;
|
||||
};
|
||||
next = step.parent.0.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Path {
|
||||
pub fn edges(&self) -> Vec<Child> {
|
||||
let mut result = Vec::new();
|
||||
let mut path = self;
|
||||
while let Some(step) = &path.0 {
|
||||
result.push(step.edge);
|
||||
path = &step.parent;
|
||||
}
|
||||
result.reverse();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) struct ExpressionKey {
|
||||
pub owner: Id,
|
||||
pub field: Field,
|
||||
pub path: Path,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum TokenRole {
|
||||
Declaration,
|
||||
Use(Role),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) struct OccurrenceKey {
|
||||
pub owner: Id,
|
||||
/// None is a global declaration header, not a binding field.
|
||||
pub field: Option<Field>,
|
||||
/// None is outside an expression; Some(empty) is its root call site.
|
||||
pub path: Option<Path>,
|
||||
pub role: TokenRole,
|
||||
pub target: Target,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Location {
|
||||
pub file: usize,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Index<'s> {
|
||||
// Private provenance: callers can inspect locations, not supply replacements
|
||||
// for the source witness, keys, catalog or occurrence spans.
|
||||
sources: &'s BTreeMap<usize, String>,
|
||||
names: BTreeMap<Target, String>,
|
||||
occurrences: BTreeMap<OccurrenceKey, Location>,
|
||||
expressions: BTreeMap<ExpressionKey, Location>,
|
||||
}
|
||||
impl<'s> Index<'s> {
|
||||
pub fn sources(&self) -> &BTreeMap<usize, String> {
|
||||
self.sources
|
||||
}
|
||||
pub fn names(&self) -> &BTreeMap<Target, String> {
|
||||
&self.names
|
||||
}
|
||||
pub fn occurrences(&self) -> &BTreeMap<OccurrenceKey, Location> {
|
||||
&self.occurrences
|
||||
}
|
||||
pub fn expressions(&self) -> &BTreeMap<ExpressionKey, Location> {
|
||||
&self.expressions
|
||||
}
|
||||
|
||||
/// Typed-only staged adapter. `ordinary` has already been parsed by its
|
||||
/// owner. Missing typed IDs fail in lowering; this never invents UUIDs.
|
||||
/// The caller merges the returned additions and admits the complete model.
|
||||
pub fn parse(
|
||||
ordinary: &Project,
|
||||
sources: &'s BTreeMap<usize, String>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(Self, Lowered), lower::Failure> {
|
||||
let mut units = Vec::new();
|
||||
let mut witnesses = BTreeMap::new();
|
||||
for (&file, source) in sources {
|
||||
let failure = |error| lower::Failure {
|
||||
file: Some(file),
|
||||
error,
|
||||
};
|
||||
budget
|
||||
.lowering_entries(units.len() + 1, Span::default())
|
||||
.map_err(failure)?;
|
||||
let tokens = lex::tokens(source, budget).map_err(failure)?;
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut parsed = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
parsed.push(declarations::parse(&mut cursor, 8, None).map_err(failure)?);
|
||||
}
|
||||
units.push(Unit {
|
||||
file,
|
||||
declarations: parsed,
|
||||
});
|
||||
witnesses.insert(file, tokens);
|
||||
}
|
||||
let lowered = lower::lower(ordinary, &units, budget)?;
|
||||
// A build failure after our own parse/lower is a phase/invariant error,
|
||||
// not an invented file assignment for an ambiguous numeric span.
|
||||
let index = Self::build(ordinary, sources, &units, &witnesses, &lowered, budget)
|
||||
.map_err(|error| lower::Failure { file: None, error })?;
|
||||
Ok((index, lowered))
|
||||
}
|
||||
|
||||
// Same-parse/lower internal seam only. Checks below reject damaged supplied
|
||||
// sites but occurrence completeness is an invariant of our own lowerer.
|
||||
// Do not expose a constructor accepting independently fabricated Lowered.
|
||||
fn build(
|
||||
ordinary: &Project,
|
||||
sources: &'s BTreeMap<usize, String>,
|
||||
units: &[Unit],
|
||||
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
|
||||
lowered: &Lowered,
|
||||
budget: &mut Budget,
|
||||
) -> Result<Self, Error> {
|
||||
budget.active()?;
|
||||
budget.lowering_entries(lowered.expressions.len(), Span::default())?;
|
||||
budget.lowering_occurrences(lowered.occurrences.len(), Span::default())?;
|
||||
let mut names = BTreeMap::new();
|
||||
let mut name_bytes = 0usize;
|
||||
let mut insert_name = |target, name: &str| -> Result<(), Error> {
|
||||
budget.lowering_entries(names.len() + 1, Span::default())?;
|
||||
name_bytes = name_bytes
|
||||
.checked_add(name.len())
|
||||
.ok_or_else(|| error(Span::default(), "Index name size overflow."))?;
|
||||
budget.index_bytes(name_bytes, Span::default())?;
|
||||
if names.insert(target, name.to_owned()).is_some() {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Duplicate qualified catalog identity.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
// Lowering has already bounded and validated the complete ordinary
|
||||
// identity catalog before this existing convenience visitor allocates.
|
||||
for (id, alias) in ordinary.identities() {
|
||||
insert_name(Target::Global(id), alias)?;
|
||||
}
|
||||
for definition in ordinary
|
||||
.definitions
|
||||
.values()
|
||||
.chain(lowered.definitions.values())
|
||||
{
|
||||
if lowered.definitions.contains_key(&definition.id) {
|
||||
insert_name(Target::Global(definition.id), &definition.alias)?;
|
||||
}
|
||||
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
for parameter in &calculation.parameters {
|
||||
insert_name(
|
||||
Target::Parameter {
|
||||
calculation: definition.id,
|
||||
parameter: parameter.id,
|
||||
},
|
||||
¶meter.name,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for element in lowered.elements.values() {
|
||||
insert_name(Target::Global(element.id), &element.alias)?;
|
||||
}
|
||||
let mut index = Self {
|
||||
sources,
|
||||
names,
|
||||
occurrences: BTreeMap::new(),
|
||||
expressions: BTreeMap::new(),
|
||||
};
|
||||
let mut owners = BTreeMap::new();
|
||||
let mut physical = BTreeSet::new();
|
||||
for unit in units {
|
||||
for declaration in &unit.declarations {
|
||||
let owner = declaration.header.identity.value.ok_or_else(|| {
|
||||
error(
|
||||
declaration.span,
|
||||
"Missing ID requires source stabilization.",
|
||||
)
|
||||
})?;
|
||||
let location = Location {
|
||||
file: unit.file,
|
||||
span: declaration.span,
|
||||
};
|
||||
checked_source(sources, location)?;
|
||||
budget.lowering_entries(owners.len() + 1, declaration.span)?;
|
||||
if owners.insert(owner, location).is_some() {
|
||||
return Err(error(declaration.span, "Duplicate source owner."));
|
||||
}
|
||||
let key = OccurrenceKey {
|
||||
owner,
|
||||
field: None,
|
||||
path: None,
|
||||
role: TokenRole::Declaration,
|
||||
target: Target::Global(owner),
|
||||
};
|
||||
index.insert(
|
||||
key,
|
||||
Location {
|
||||
file: unit.file,
|
||||
span: declaration.header.alias.span,
|
||||
},
|
||||
witnesses,
|
||||
&mut physical,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Parent-before-child is part of the lowering contract. One forward
|
||||
// pass rejects forward, cyclic, cross-file/owner/field and duplicate
|
||||
// paths. Each node allocates one shared edge, not a copied path vector.
|
||||
let mut paths: Vec<(ExpressionKey, usize)> = Vec::new();
|
||||
for (position, site) in lowered.expressions.iter().enumerate() {
|
||||
if !matches!(
|
||||
site.field,
|
||||
Field::Body | Field::Base | Field::Predicate | Field::Override(_)
|
||||
) {
|
||||
return Err(error(
|
||||
site.span,
|
||||
"Expression occurs outside a binding field.",
|
||||
));
|
||||
}
|
||||
let location = Location {
|
||||
file: site.file,
|
||||
span: site.span,
|
||||
};
|
||||
checked_source(sources, location)?;
|
||||
let owner = owners
|
||||
.get(&site.owner)
|
||||
.ok_or_else(|| error(site.span, "Unknown expression owner."))?;
|
||||
contained(*owner, location)?;
|
||||
let (path, depth) = if let Some((parent, edge)) = site.parent {
|
||||
if parent >= position {
|
||||
return Err(error(
|
||||
site.span,
|
||||
"Expression parent must precede its child.",
|
||||
));
|
||||
}
|
||||
let ancestor = &lowered.expressions[parent];
|
||||
if ancestor.owner != site.owner
|
||||
|| ancestor.field != site.field
|
||||
|| ancestor.file != site.file
|
||||
{
|
||||
return Err(error(
|
||||
site.span,
|
||||
"Expression parent crosses its owner, field or file.",
|
||||
));
|
||||
}
|
||||
contained(
|
||||
Location {
|
||||
file: ancestor.file,
|
||||
span: ancestor.span,
|
||||
},
|
||||
location,
|
||||
)?;
|
||||
if ancestor.span == site.span {
|
||||
return Err(error(
|
||||
site.span,
|
||||
"Child expression must be a proper source subtree.",
|
||||
));
|
||||
}
|
||||
let depth = paths[parent].1 + 1;
|
||||
budget.frame(depth, site.span)?;
|
||||
(
|
||||
Path(Some(Arc::new(Step {
|
||||
parent: paths[parent].0.path.clone(),
|
||||
edge,
|
||||
}))),
|
||||
depth,
|
||||
)
|
||||
} else {
|
||||
budget.frame(1, site.span)?;
|
||||
(Path::default(), 1)
|
||||
};
|
||||
let key = ExpressionKey {
|
||||
owner: site.owner,
|
||||
field: site.field,
|
||||
path,
|
||||
};
|
||||
if index.expressions.insert(key.clone(), location).is_some() {
|
||||
return Err(error(site.span, "Duplicate qualified expression path."));
|
||||
}
|
||||
paths.push((key, depth));
|
||||
}
|
||||
for occurrence in &lowered.occurrences {
|
||||
let location = Location {
|
||||
file: occurrence.file,
|
||||
span: occurrence.span,
|
||||
};
|
||||
let owner = owners
|
||||
.get(&occurrence.owner)
|
||||
.ok_or_else(|| error(occurrence.span, "Unknown occurrence owner."))?;
|
||||
contained(*owner, location)?;
|
||||
let path = match occurrence.expression {
|
||||
Some(site) => {
|
||||
let expression = lowered
|
||||
.expressions
|
||||
.get(site)
|
||||
.ok_or_else(|| error(occurrence.span, "Unknown occurrence expression."))?;
|
||||
if expression.owner != occurrence.owner
|
||||
|| expression.field != occurrence.field
|
||||
|| expression.file != occurrence.file
|
||||
{
|
||||
return Err(error(
|
||||
occurrence.span,
|
||||
"Occurrence crosses its expression owner, field or file.",
|
||||
));
|
||||
}
|
||||
contained(
|
||||
Location {
|
||||
file: expression.file,
|
||||
span: expression.span,
|
||||
},
|
||||
location,
|
||||
)?;
|
||||
Some(paths[site].0.path.clone())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
match (occurrence.role, occurrence.target) {
|
||||
(
|
||||
Role::ParameterDeclaration,
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
},
|
||||
) if calculation == occurrence.owner
|
||||
&& occurrence.field == Field::Input(parameter)
|
||||
&& path.is_none() => {}
|
||||
(Role::ParameterUse, Target::Parameter { calculation, .. })
|
||||
if calculation == occurrence.owner
|
||||
&& occurrence.field == Field::Body
|
||||
&& path.is_some() => {}
|
||||
(Role::ArgumentLabel, Target::Parameter { .. }) if path.is_some() => {}
|
||||
(Role::OverrideTarget, Target::Global(id))
|
||||
if occurrence.field == Field::Override(id) && path.is_none() => {}
|
||||
(Role::CallTarget, Target::Global(_)) if path.is_some() => {}
|
||||
(Role::Reference, Target::Global(_)) => {}
|
||||
_ => return Err(error(occurrence.span, "Invalid qualified occurrence role.")),
|
||||
}
|
||||
let key = OccurrenceKey {
|
||||
owner: occurrence.owner,
|
||||
field: Some(occurrence.field),
|
||||
path,
|
||||
role: TokenRole::Use(occurrence.role),
|
||||
target: occurrence.target,
|
||||
};
|
||||
index.insert(key, location, witnesses, &mut physical, budget)?;
|
||||
}
|
||||
// Argument labels belong to this precise call site and its callee, not
|
||||
// merely to a parameter UUID/name present somewhere in the catalog.
|
||||
let mut calls = BTreeMap::new();
|
||||
for (key, at) in &index.occurrences {
|
||||
if key.role == TokenRole::Use(Role::CallTarget) {
|
||||
let site = (key.owner, key.field, key.path.clone());
|
||||
if calls.insert(site, key.target).is_some() {
|
||||
return Err(error(at.span, "Multiple callees at one expression site."));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, at) in &index.occurrences {
|
||||
if let (TokenRole::Use(Role::ArgumentLabel), Target::Parameter { calculation, .. }) =
|
||||
(key.role, key.target)
|
||||
&& calls.get(&(key.owner, key.field, key.path.clone()))
|
||||
!= Some(&Target::Global(calculation))
|
||||
{
|
||||
return Err(error(
|
||||
at.span,
|
||||
"Argument label crosses its called calculation.",
|
||||
));
|
||||
}
|
||||
}
|
||||
for (position, site) in lowered.expressions.iter().enumerate() {
|
||||
if let Some((parent, Child::Argument(parameter))) = site.parent {
|
||||
let key = &paths[parent].0;
|
||||
let Some(Target::Global(calculation)) =
|
||||
calls.get(&(key.owner, Some(key.field), Some(key.path.clone())))
|
||||
else {
|
||||
return Err(error(site.span, "Argument child has no owning call."));
|
||||
};
|
||||
let label = OccurrenceKey {
|
||||
owner: site.owner,
|
||||
field: Some(site.field),
|
||||
path: Some(key.path.clone()),
|
||||
role: TokenRole::Use(Role::ArgumentLabel),
|
||||
target: Target::Parameter {
|
||||
calculation: *calculation,
|
||||
parameter,
|
||||
},
|
||||
};
|
||||
if !index.occurrences.contains_key(&label) || paths[position].0.path == key.path {
|
||||
return Err(error(site.span, "Argument child has no qualified label."));
|
||||
}
|
||||
}
|
||||
}
|
||||
index.check_syntax_edges(units, lowered, budget)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Check structural edges against the already parsed syntax, not just the
|
||||
/// arena's internally consistent claims. This is one iterative tree walk,
|
||||
/// with indexed span lookup; it does not lex or copy source/path strings.
|
||||
fn check_syntax_edges<'a>(
|
||||
&self,
|
||||
units: &'a [Unit],
|
||||
lowered: &Lowered,
|
||||
budget: &Budget,
|
||||
) -> Result<(), Error> {
|
||||
let mut globals = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
for (target, name) in &self.names {
|
||||
match target {
|
||||
Target::Global(id) => {
|
||||
globals.insert(name.as_str(), *id);
|
||||
}
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
} => {
|
||||
parameters.insert((*calculation, name.as_str()), *parameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sites = BTreeMap::new();
|
||||
for (position, site) in lowered.expressions.iter().enumerate() {
|
||||
if sites
|
||||
.insert(
|
||||
(
|
||||
site.file,
|
||||
site.owner,
|
||||
site.field,
|
||||
site.span.start,
|
||||
site.span.end,
|
||||
),
|
||||
position,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err(error(site.span, "Duplicate expression source ownership."));
|
||||
}
|
||||
}
|
||||
let mut work = Vec::new();
|
||||
let mut root = |file, owner, field, binding: &'a super::Binding| -> Result<(), Error> {
|
||||
if let super::Binding::Expression(expression) = binding {
|
||||
budget.lowering_entries(work.len() + 1, expression.span)?;
|
||||
work.push((file, owner, field, expression, None));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for unit in units {
|
||||
for declaration in &unit.declarations {
|
||||
let owner = declaration.header.identity.value.expect("validated owner");
|
||||
match &declaration.body {
|
||||
declarations::Body::Calculation { body, .. } => {
|
||||
root(unit.file, owner, Field::Body, &body.value)?
|
||||
}
|
||||
declarations::Body::Property { base, .. } => {
|
||||
root(unit.file, owner, Field::Base, &base.value)?
|
||||
}
|
||||
declarations::Body::Check { predicate, .. } => {
|
||||
root(unit.file, owner, Field::Predicate, &predicate.value)?
|
||||
}
|
||||
declarations::Body::Case { overrides, .. } => {
|
||||
for entry in &overrides.value {
|
||||
let target =
|
||||
*globals.get(entry.target.name.as_str()).ok_or_else(|| {
|
||||
error(entry.target.span, "Unknown syntax override target.")
|
||||
})?;
|
||||
root(unit.file, owner, Field::Override(target), &entry.binding)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut visited = 0usize;
|
||||
while let Some((file, owner, field, expression, parent)) = work.pop() {
|
||||
budget.lowering_entries(visited + 1, expression.span)?;
|
||||
visited += 1;
|
||||
let site = *sites
|
||||
.get(&(
|
||||
file,
|
||||
owner,
|
||||
field,
|
||||
expression.span.start,
|
||||
expression.span.end,
|
||||
))
|
||||
.ok_or_else(|| {
|
||||
error(
|
||||
expression.span,
|
||||
"Parsed expression is absent from the source arena.",
|
||||
)
|
||||
})?;
|
||||
if lowered.expressions[site].parent != parent {
|
||||
return Err(error(
|
||||
expression.span,
|
||||
"Arena parent/child edge differs from original syntax.",
|
||||
));
|
||||
}
|
||||
let mut child = |value: &'a super::Expression, edge| -> Result<(), Error> {
|
||||
budget.lowering_entries(work.len() + 1, value.span)?;
|
||||
work.push((file, owner, field, value, Some((site, edge))));
|
||||
Ok(())
|
||||
};
|
||||
use super::ExpressionKind as E;
|
||||
match &expression.kind {
|
||||
E::List(items) => {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
child(item, Child::Item(index))?;
|
||||
}
|
||||
}
|
||||
E::Unary { operand, .. } => child(operand, Child::Operand)?,
|
||||
E::Binary { left, right, .. } => {
|
||||
child(left, Child::Left)?;
|
||||
child(right, Child::Right)?;
|
||||
}
|
||||
E::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
child(condition, Child::Condition)?;
|
||||
child(when_true, Child::WhenTrue)?;
|
||||
child(when_false, Child::WhenFalse)?;
|
||||
}
|
||||
E::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
let calculation = *globals
|
||||
.get(definition.name.as_str())
|
||||
.ok_or_else(|| error(definition.span, "Unknown syntax call target."))?;
|
||||
for argument in arguments {
|
||||
let parameter = *parameters
|
||||
.get(&(calculation, argument.parameter.name.as_str()))
|
||||
.ok_or_else(|| {
|
||||
error(argument.parameter.span, "Unknown syntax call parameter.")
|
||||
})?;
|
||||
child(&argument.value, Child::Argument(parameter))?;
|
||||
}
|
||||
}
|
||||
E::Sum { values, initial } => {
|
||||
child(values, Child::Values)?;
|
||||
child(initial, Child::Initial)?;
|
||||
}
|
||||
E::Power { base, .. } => child(base, Child::Base)?,
|
||||
E::AsKind { value, .. } => child(value, Child::Value)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if visited != lowered.expressions.len() {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Arena contains expressions absent from parsed syntax.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert(
|
||||
&mut self,
|
||||
key: OccurrenceKey,
|
||||
location: Location,
|
||||
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
|
||||
physical: &mut BTreeSet<(usize, usize, usize)>,
|
||||
budget: &Budget,
|
||||
) -> Result<(), Error> {
|
||||
budget.lowering_occurrences(self.occurrences.len() + 1, location.span)?;
|
||||
let text = checked_source(self.sources, location)?;
|
||||
if self.names.get(&key.target).is_none_or(|name| name != text) {
|
||||
return Err(error(
|
||||
location.span,
|
||||
"Token differs from its qualified target name.",
|
||||
));
|
||||
}
|
||||
let tokens = witnesses
|
||||
.get(&location.file)
|
||||
.ok_or_else(|| error(location.span, "Missing lexical witness."))?;
|
||||
let token = tokens
|
||||
.binary_search_by_key(&location.span.start, |token| token.span.start)
|
||||
.ok()
|
||||
.map(|at| tokens[at]);
|
||||
if token.is_none_or(|token| token.span != location.span || token.class != lex::Class::Word)
|
||||
{
|
||||
return Err(error(
|
||||
location.span,
|
||||
"Occurrence is not an original whole name token.",
|
||||
));
|
||||
}
|
||||
if self.occurrences.contains_key(&key)
|
||||
|| !physical.insert((location.file, location.span.start, location.span.end))
|
||||
{
|
||||
return Err(error(
|
||||
location.span,
|
||||
"Duplicate qualified occurrence or token ownership.",
|
||||
));
|
||||
}
|
||||
self.occurrences.insert(key, location);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn checked_source(
|
||||
sources: &BTreeMap<usize, String>,
|
||||
location: Location,
|
||||
) -> Result<&str, Error> {
|
||||
if location.span.start >= location.span.end {
|
||||
return Err(error(location.span, "Empty or reversed source span."));
|
||||
}
|
||||
sources
|
||||
.get(&location.file)
|
||||
.and_then(|source| source.get(location.span.start..location.span.end))
|
||||
.ok_or_else(|| {
|
||||
error(
|
||||
location.span,
|
||||
"Source span is outside its original UTF-8 file.",
|
||||
)
|
||||
})
|
||||
}
|
||||
fn contained(parent: Location, child: Location) -> Result<(), Error> {
|
||||
if parent.file != child.file
|
||||
|| child.span.start < parent.span.start
|
||||
|| child.span.end > parent.span.end
|
||||
{
|
||||
return Err(error(
|
||||
child.span,
|
||||
"Source span crosses its containing syntax.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,630 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
|
||||
const OA: &str = include_str!("../lower/fixtures/typed-oa.arcadia");
|
||||
const ORDINARY: &str = include_str!("../lower/fixtures/ordinary-oa.arcadia");
|
||||
fn budget() -> Budget {
|
||||
Budget::new(super::super::budget::Limits::source8(
|
||||
ParseLimits::UNLIMITED,
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
fn ordinary() -> Project {
|
||||
crate::language::parse(ORDINARY).unwrap()
|
||||
}
|
||||
fn sources() -> BTreeMap<usize, String> {
|
||||
let split = OA.find("calculation combine").unwrap();
|
||||
BTreeMap::from([
|
||||
(7, OA[..split].replace('\n', "\r\n")),
|
||||
(19, OA[split..].to_owned()),
|
||||
])
|
||||
}
|
||||
fn raw(
|
||||
sources: &BTreeMap<usize, String>,
|
||||
budget: &mut Budget,
|
||||
) -> (Vec<Unit>, BTreeMap<usize, Vec<lex::Token>>, Lowered) {
|
||||
let mut units = Vec::new();
|
||||
let mut witnesses = BTreeMap::new();
|
||||
for (&file, source) in sources {
|
||||
let tokens = lex::tokens(source, budget).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut declarations = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
declarations.push(declarations::parse(&mut cursor, 8, None).unwrap());
|
||||
}
|
||||
units.push(Unit { file, declarations });
|
||||
witnesses.insert(file, tokens);
|
||||
}
|
||||
let lowered = lower::lower(&ordinary(), &units, budget).unwrap();
|
||||
(units, witnesses, lowered)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_index_preserves_complete_oa_sa_models_and_original_file_spans() {
|
||||
for (layer, ordinary_source, typed) in [
|
||||
(crate::Layer::Oa, ORDINARY, OA),
|
||||
(
|
||||
crate::Layer::Sa,
|
||||
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
|
||||
include_str!("../lower/fixtures/typed-sa.arcadia"),
|
||||
),
|
||||
] {
|
||||
let sources = BTreeMap::from([(37, typed.replace('\n', "\r\n"))]);
|
||||
let skeleton = crate::language::parse(ordinary_source).unwrap();
|
||||
let mut work = budget();
|
||||
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
|
||||
let mut actual = skeleton;
|
||||
actual.definitions.extend(lowered.definitions);
|
||||
actual.elements.extend(lowered.elements);
|
||||
assert_eq!(actual, super::super::model_oracle::project(layer));
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
assert_eq!(work.usage.bytes, sources[&37].len(), "one lexical pass");
|
||||
for (key, at) in index.occurrences() {
|
||||
assert_eq!(at.file, 37);
|
||||
assert_eq!(
|
||||
&sources[&37][at.span.start..at.span.end],
|
||||
index.names[&key.target]
|
||||
);
|
||||
}
|
||||
assert!(!index.expressions().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_reordered_calls_have_stable_full_paths_and_four_labels() {
|
||||
let expression = "@combine(b: @dispatch_time, a: @receive_time) // first 雪\n + @combine(a: @receive_time, b: @dispatch_time)";
|
||||
let original = OA.replace("@combine(a: @receive_time, b: @dispatch_time)", expression);
|
||||
let reordered = original.replace(
|
||||
"@combine(b: @dispatch_time, a: @receive_time)",
|
||||
"@combine(a: @receive_time, b: @dispatch_time)",
|
||||
);
|
||||
let first = BTreeMap::from([(7, original)]);
|
||||
let second = BTreeMap::from([(7, reordered)]);
|
||||
let (left, _) = Index::parse(&ordinary(), &first, &mut budget()).unwrap();
|
||||
let (right, _) = Index::parse(&ordinary(), &second, &mut budget()).unwrap();
|
||||
assert_eq!(
|
||||
left.occurrences.keys().collect::<Vec<_>>(),
|
||||
right.occurrences.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
left.expressions.keys().collect::<Vec<_>>(),
|
||||
right.expressions.keys().collect::<Vec<_>>()
|
||||
);
|
||||
let labels: Vec<_> = left
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|(key, _)| key.role == TokenRole::Use(Role::ArgumentLabel))
|
||||
.collect();
|
||||
assert_eq!(labels.len(), 4);
|
||||
let paths: BTreeSet<_> = labels
|
||||
.iter()
|
||||
.map(|(key, _)| key.path.as_ref().unwrap().edges())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
BTreeSet::from([vec![Child::Left], vec![Child::Right]])
|
||||
);
|
||||
let spans: BTreeSet<_> = labels
|
||||
.iter()
|
||||
.map(|(_, at)| (at.span.start, at.span.end))
|
||||
.collect();
|
||||
assert_eq!(spans.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_checks_every_expression_edge_and_keeps_override_fields_distinct() {
|
||||
let source = OA
|
||||
.replace("body $a + $b;", "body if(if(true, 1 < 2, false), sum([$a, abs(-$b)], 0 [@second]), as_kind(@duration, power(1 [@second], 2) / 1 [@second]));")
|
||||
.replace("@receive_time = 1250 [@millisecond];", "@receive_time = @combine(a: 1 [@second], b: 2 [@second]); @dispatch_time = @combine(b: 2 [@second], a: 1 [@second]);");
|
||||
let sources = BTreeMap::from([(17, source)]);
|
||||
let (index, lowered) = Index::parse(&ordinary(), &sources, &mut budget()).unwrap();
|
||||
let mut project = ordinary();
|
||||
project.definitions.extend(lowered.definitions);
|
||||
project.elements.extend(lowered.elements);
|
||||
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
let edges: BTreeSet<_> = index
|
||||
.expressions
|
||||
.keys()
|
||||
.flat_map(|key| key.path.edges())
|
||||
.collect();
|
||||
for edge in [
|
||||
Child::Item(0),
|
||||
Child::Item(1),
|
||||
Child::Operand,
|
||||
Child::Left,
|
||||
Child::Right,
|
||||
Child::Condition,
|
||||
Child::WhenTrue,
|
||||
Child::WhenFalse,
|
||||
Child::Values,
|
||||
Child::Initial,
|
||||
Child::Base,
|
||||
Child::Value,
|
||||
] {
|
||||
assert!(edges.contains(&edge), "missing {edge:?}");
|
||||
}
|
||||
assert!(edges.iter().any(|edge| matches!(edge, Child::Argument(_))));
|
||||
let labels: Vec<_> = index
|
||||
.occurrences
|
||||
.keys()
|
||||
.filter(|key| {
|
||||
key.role == TokenRole::Use(Role::ArgumentLabel)
|
||||
&& matches!(key.field, Some(Field::Override(_)))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels.len(), 4);
|
||||
assert_eq!(
|
||||
labels
|
||||
.iter()
|
||||
.map(|key| key.field)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
labels
|
||||
.iter()
|
||||
.all(|key| key.path.as_ref().unwrap().edges().is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arena_rejects_forward_cycles_and_cross_owner_field_file_parents() {
|
||||
for damage in 0..6 {
|
||||
let sources = sources();
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let child = lowered
|
||||
.expressions
|
||||
.iter()
|
||||
.position(|site| site.parent.is_some())
|
||||
.unwrap();
|
||||
let parent = lowered.expressions[child].parent.unwrap().0;
|
||||
match damage {
|
||||
0 => lowered.expressions[child].parent = Some((child, Child::Left)),
|
||||
1 => lowered.expressions[parent].parent = Some((child, Child::Right)),
|
||||
2 => lowered.expressions[child].owner = ordinary().id,
|
||||
3 => lowered.expressions[child].field = Field::Predicate,
|
||||
4 => lowered.expressions[child].file = 12345,
|
||||
_ => lowered.expressions[child].parent = Some((parent, Child::Operand)),
|
||||
}
|
||||
assert!(
|
||||
Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work
|
||||
)
|
||||
.is_err(),
|
||||
"damage {damage}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() {
|
||||
for damage in 0..4 {
|
||||
let mut sources = sources();
|
||||
// The other callee is fully valid and reuses both parameter identities
|
||||
// and spellings. Catalog/token validation must not explain rejection.
|
||||
sources.insert(
|
||||
31,
|
||||
r#"calculation other "Other" id "00000000-0000-0000-0000-00000000cafe" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}"#
|
||||
.into(),
|
||||
);
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let valid = Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work,
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected_mismatch_span = None;
|
||||
match damage {
|
||||
0 => {
|
||||
let site = &lowered.expressions[0];
|
||||
lowered.expressions.push(lower::ExpressionSite {
|
||||
file: site.file,
|
||||
owner: site.owner,
|
||||
field: site.field,
|
||||
parent: site.parent,
|
||||
span: site.span,
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
let row = &lowered.occurrences[0];
|
||||
lowered.occurrences.push(lower::Occurrence {
|
||||
file: row.file,
|
||||
owner: row.owner,
|
||||
field: row.field,
|
||||
expression: row.expression,
|
||||
role: row.role,
|
||||
target: row.target,
|
||||
span: row.span,
|
||||
});
|
||||
}
|
||||
2 => {
|
||||
let label = lowered
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|row| row.role == Role::ArgumentLabel)
|
||||
.unwrap();
|
||||
let original = label.target;
|
||||
let Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
} = &mut label.target
|
||||
else {
|
||||
panic!("argument label must target a parameter");
|
||||
};
|
||||
let other = Target::Parameter {
|
||||
calculation: Id::from_u128(0xcafe),
|
||||
parameter: *parameter,
|
||||
};
|
||||
assert_eq!(valid.names[&original], valid.names[&other]);
|
||||
*calculation = Id::from_u128(0xcafe);
|
||||
expected_mismatch_span = Some(label.span);
|
||||
}
|
||||
_ => {
|
||||
let row = lowered
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|row| row.role == Role::OverrideTarget)
|
||||
.unwrap();
|
||||
row.field = Field::Override(ordinary().id);
|
||||
}
|
||||
}
|
||||
let failure = Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work,
|
||||
)
|
||||
.unwrap_err();
|
||||
if let Some(span) = expected_mismatch_span {
|
||||
assert_eq!(
|
||||
failure.message,
|
||||
"Argument label crosses its called calculation."
|
||||
);
|
||||
assert_eq!(failure.span, span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_rejects_comment_partial_and_non_utf8_occurrence_spans() {
|
||||
for damage in 0..3 {
|
||||
let mut sources = sources();
|
||||
let with_comment =
|
||||
sources[&7].replace("subject @receive;", "subject @receive; // duration 雪\r\n");
|
||||
sources.insert(7, with_comment);
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let occurrence = lowered
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|row| row.file == 7 && &sources[&7][row.span.start..row.span.end] == "duration")
|
||||
.unwrap();
|
||||
match damage {
|
||||
0 => {
|
||||
let start = sources[&7].find("// duration").unwrap() + 3;
|
||||
occurrence.span = Span {
|
||||
start,
|
||||
end: start + 8,
|
||||
};
|
||||
}
|
||||
1 => occurrence.span.end -= 1,
|
||||
_ => {
|
||||
let start = sources[&7].find('雪').unwrap() + 1;
|
||||
occurrence.span = Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_has_shared_budget_and_never_stabilizes_missing_ids() {
|
||||
let source = "calculation f \"F\" { result Number; body unbound; }";
|
||||
let files = BTreeMap::from([(91, source.into())]);
|
||||
let missing = Index::parse(&ordinary(), &files, &mut budget()).unwrap_err();
|
||||
assert_eq!(missing.file, Some(91));
|
||||
assert!(missing.error.message.contains("stabilization"));
|
||||
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.bytes = 1;
|
||||
let mut work = Budget::new(limits).unwrap();
|
||||
assert!(Index::parse(&ordinary(), &sources(), &mut work).is_err());
|
||||
assert!(work.active().is_err());
|
||||
}
|
||||
|
||||
fn on_small_stack(work: impl FnOnce() + Send + 'static) {
|
||||
// Match the established core language_nesting_tests worker-stack request.
|
||||
// This is a requested platform stack size, not a measured peak-byte claim.
|
||||
std::thread::Builder::new()
|
||||
.name("analysis-index-128k".into())
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(work)
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn chain(first: Child, edges: usize) -> Path {
|
||||
let mut path = Path::default();
|
||||
for position in 0..edges {
|
||||
path = Path(Some(Arc::new(Step {
|
||||
parent: path,
|
||||
edge: if position == 0 { first } else { Child::Operand },
|
||||
})));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_index_paths_compare_lookup_debug_and_drop_on_128k_stack() {
|
||||
on_small_stack(|| {
|
||||
let frames = super::super::budget::Limits::source8(ParseLimits::UNLIMITED).frames;
|
||||
assert_eq!(frames, 256);
|
||||
let edges = frames - 1; // root has frame depth one
|
||||
let mut work = budget();
|
||||
work.frame(frames, Span::default()).unwrap();
|
||||
let left = chain(Child::Left, edges);
|
||||
let equal = chain(Child::Left, edges); // independent allocations
|
||||
let right = chain(Child::Right, edges);
|
||||
assert!(!Arc::ptr_eq(
|
||||
left.0.as_ref().unwrap(),
|
||||
equal.0.as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(left, equal);
|
||||
assert_eq!(left.partial_cmp(&equal), Some(Ordering::Equal));
|
||||
// The difference is at the root-most edge: leaf-first walks every link.
|
||||
assert_eq!(left.cmp(&right), Child::Left.cmp(&Child::Right));
|
||||
assert_eq!(right.cmp(&left), left.cmp(&right).reverse());
|
||||
assert!(Path::default() < left);
|
||||
assert!(chain(Child::Operand, edges - 1) < chain(Child::Operand, edges));
|
||||
let shared = left.clone();
|
||||
assert!(Arc::ptr_eq(
|
||||
left.0.as_ref().unwrap(),
|
||||
shared.0.as_ref().unwrap()
|
||||
));
|
||||
let leaf = Arc::downgrade(left.0.as_ref().unwrap());
|
||||
let key = |path| ExpressionKey {
|
||||
owner: Id::from_u128(1),
|
||||
field: Field::Body,
|
||||
path,
|
||||
};
|
||||
let mut expressions = BTreeMap::new();
|
||||
assert_eq!(expressions.insert(key(left.clone()), 1), None);
|
||||
assert_eq!(expressions.insert(key(equal.clone()), 2), Some(1));
|
||||
assert_eq!(expressions.insert(key(right.clone()), 3), None);
|
||||
assert_eq!(expressions.get(&key(equal.clone())), Some(&2));
|
||||
assert_eq!(expressions.get(&key(chain(Child::Right, edges))), Some(&3));
|
||||
assert_eq!(expressions.len(), 2);
|
||||
let occurrence = |path| OccurrenceKey {
|
||||
owner: Id::from_u128(1),
|
||||
field: Some(Field::Body),
|
||||
path: Some(path),
|
||||
role: TokenRole::Use(Role::ParameterUse),
|
||||
target: Target::Parameter {
|
||||
calculation: Id::from_u128(1),
|
||||
parameter: a::ParameterId(Id::from_u128(2)),
|
||||
},
|
||||
};
|
||||
let mut occurrences = BTreeSet::new();
|
||||
assert!(occurrences.insert(occurrence(left.clone())));
|
||||
assert!(!occurrences.insert(occurrence(equal.clone())));
|
||||
assert!(occurrences.contains(&occurrence(chain(Child::Left, edges))));
|
||||
let debug = format!("{left:?}");
|
||||
assert_eq!(debug.matches("Operand").count(), edges - 1);
|
||||
assert!(debug.ends_with("Left]"));
|
||||
assert!(!format!("{expressions:?} {occurrences:?}").is_empty());
|
||||
drop(expressions);
|
||||
drop(occurrences);
|
||||
drop(shared);
|
||||
drop(left);
|
||||
assert!(leaf.upgrade().is_none(), "maps/clones release their chain");
|
||||
drop(equal);
|
||||
drop(right);
|
||||
// Dropping a child stops at a shared ancestor, which remains usable.
|
||||
let parent = chain(Child::Left, edges - 1);
|
||||
let ancestor = Arc::downgrade(parent.0.as_ref().unwrap());
|
||||
let child = Path(Some(Arc::new(Step {
|
||||
parent: parent.clone(),
|
||||
edge: Child::Right,
|
||||
})));
|
||||
drop(child);
|
||||
assert_eq!(parent.edges().len(), edges - 1);
|
||||
drop(parent);
|
||||
assert!(ancestor.upgrade().is_none());
|
||||
// No other strong owners: final destruction must peel the full chain.
|
||||
let unique = chain(Child::Left, edges);
|
||||
let final_leaf = Arc::downgrade(unique.0.as_ref().unwrap());
|
||||
drop(unique);
|
||||
assert!(final_leaf.upgrade().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_parse_index_plan_and_failed_build_cleanup_on_128k_stack() {
|
||||
on_small_stack(|| {
|
||||
use super::super::update::Plan;
|
||||
let deep = format!("{}$a{}", "abs(".repeat(127), ")".repeat(127));
|
||||
let source = format!(
|
||||
r#"calculation f "Deep 雪" id "00000000-0000-0000-0000-000000000081" {{
|
||||
input a id "00000000-0000-0000-0000-000000000083" : Number;
|
||||
result Number; body {deep};
|
||||
}}
|
||||
calculation caller "Caller" id "00000000-0000-0000-0000-000000000082" {{
|
||||
result Number; body @f(a: 1);
|
||||
}}
|
||||
"#
|
||||
);
|
||||
let sources = BTreeMap::from([(47, source.clone())]);
|
||||
let skeleton = ordinary();
|
||||
let mut work = budget();
|
||||
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
|
||||
assert_eq!(work.usage.bytes, source.len());
|
||||
assert_eq!(index.expressions.len(), 130); // 128 unary/leaf + call/argument
|
||||
assert_eq!(
|
||||
index.expressions.keys().map(|k| k.path.edges().len()).max(),
|
||||
Some(127)
|
||||
);
|
||||
let renames = BTreeMap::from([
|
||||
(Target::Global(Id::from_u128(0x81)), "renamed_deep".into()),
|
||||
(
|
||||
Target::Parameter {
|
||||
calculation: Id::from_u128(0x81),
|
||||
parameter: a::ParameterId(Id::from_u128(0x83)),
|
||||
},
|
||||
"renamed_arg".into(),
|
||||
),
|
||||
]);
|
||||
let usage = work.usage;
|
||||
let plan = Plan::renames(&index, &renames, &work).unwrap();
|
||||
assert_eq!(plan.edit_count(), 5); // declaration/call + input/use/label
|
||||
let output = plan.apply(&sources, &work).unwrap();
|
||||
assert_eq!(work.usage, usage);
|
||||
// Independent complete original-token oracle; no index-derived offsets.
|
||||
let expected_source = source
|
||||
.replace("calculation f ", "calculation renamed_deep ")
|
||||
.replace("input a id", "input renamed_arg id")
|
||||
.replace("$a", "$renamed_arg")
|
||||
.replace("@f(a: 1)", "@renamed_deep(renamed_arg: 1)");
|
||||
assert_eq!(output, BTreeMap::from([(47, expected_source)]));
|
||||
let (renamed_index, renamed) = Index::parse(&skeleton, &output, &mut budget()).unwrap();
|
||||
assert_eq!(
|
||||
index.occurrences.keys().collect::<Vec<_>>(),
|
||||
renamed_index.occurrences.keys().collect::<Vec<_>>()
|
||||
);
|
||||
// Compare complete independently constructed models, including deepest
|
||||
// parameter identity and the call's parameter-keyed argument map.
|
||||
for (lowered, renamed) in [(lowered, false), (renamed, true)] {
|
||||
let mut expected = skeleton.clone();
|
||||
let parameter = a::ParameterId(Id::from_u128(0x83));
|
||||
let mut expression = a::Expr::Parameter(parameter);
|
||||
for _ in 0..127 {
|
||||
expression = a::Expr::Unary {
|
||||
op: a::UnaryOp::Absolute,
|
||||
operand: Box::new(expression),
|
||||
};
|
||||
}
|
||||
for (id, alias, name, parameters, expression) in [
|
||||
(
|
||||
0x81,
|
||||
if renamed { "renamed_deep" } else { "f" },
|
||||
"Deep 雪",
|
||||
vec![a::Parameter {
|
||||
id: parameter,
|
||||
name: if renamed { "renamed_arg" } else { "a" }.into(),
|
||||
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
}],
|
||||
expression,
|
||||
),
|
||||
(
|
||||
0x82,
|
||||
"caller",
|
||||
"Caller",
|
||||
Vec::new(),
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(Id::from_u128(0x81)),
|
||||
arguments: BTreeMap::from([(
|
||||
parameter,
|
||||
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(1)),
|
||||
)]),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let id = Id::from_u128(id);
|
||||
expected.definitions.insert(
|
||||
id,
|
||||
a::Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters,
|
||||
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
body: a::Binding::Expression(expression),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut actual = skeleton.clone();
|
||||
actual.definitions.extend(lowered.definitions);
|
||||
actual.elements.extend(lowered.elements);
|
||||
assert_eq!(actual, expected);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
drop(actual);
|
||||
drop(expected);
|
||||
}
|
||||
drop(plan);
|
||||
drop(renamed_index);
|
||||
drop(index);
|
||||
// Failure after the deepest keys have been allocated must clean up on
|
||||
// the same stack; no modified Lowered escapes this private test seam.
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let deepest = lowered
|
||||
.expressions
|
||||
.iter()
|
||||
.find(|site| &sources[&site.file][site.span.start..site.span.end] == "$a")
|
||||
.unwrap();
|
||||
lowered.expressions.push(lower::ExpressionSite {
|
||||
file: deepest.file,
|
||||
owner: deepest.owner,
|
||||
field: deepest.field,
|
||||
parent: deepest.parent,
|
||||
span: deepest.span,
|
||||
});
|
||||
let failure =
|
||||
Index::build(&skeleton, &sources, &units, &witnesses, &lowered, &mut work).unwrap_err();
|
||||
assert_eq!(failure.message, "Duplicate qualified expression path.");
|
||||
lowered.expressions.pop();
|
||||
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.frames = 127;
|
||||
let mut bounded = Budget::new(limits).unwrap();
|
||||
let failure = Index::build(
|
||||
&skeleton,
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut bounded,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("syntax frames"));
|
||||
assert!(bounded.active().is_err());
|
||||
drop(lowered);
|
||||
drop(witnesses);
|
||||
drop(units);
|
||||
let rejected = BTreeMap::from([(47, source.replace(&deep, &format!("abs({deep})")))]);
|
||||
let failure = Index::parse(&skeleton, &rejected, &mut budget()).unwrap_err();
|
||||
assert!(failure.error.message.contains("logical depth"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Source8 significant-token adapter over the shared raw scanner.
|
||||
use super::super::raw_tokens::{Class as RawClass, Scanner};
|
||||
use super::{Budget, Error, Span, error};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
Word,
|
||||
Number,
|
||||
String,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
pub class: Class,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
pub fn tokens(source: &str, budget: &mut Budget) -> Result<Vec<Token>, Error> {
|
||||
budget.bytes(
|
||||
source.len(),
|
||||
Span {
|
||||
start: 0,
|
||||
end: source.len(),
|
||||
},
|
||||
)?;
|
||||
let mut scanner = Scanner::new(source);
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < source.len() {
|
||||
// Charge trivia too, before the scanner advances or a token is stored.
|
||||
budget.token(Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
})?;
|
||||
let token = scanner.next().expect("source remains");
|
||||
offset = token.span.end;
|
||||
let class = match token.class {
|
||||
RawClass::Word => Class::Word,
|
||||
RawClass::Number => Class::Number,
|
||||
RawClass::String => Class::String,
|
||||
RawClass::Punctuation => Class::Punctuation,
|
||||
RawClass::Whitespace | RawClass::Comment => continue,
|
||||
RawClass::Error => return Err(error(token.span, "Invalid source token.")),
|
||||
};
|
||||
result.push(Token {
|
||||
class,
|
||||
span: token.span,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
//! Staged typed resolution. The caller owns parsing, ID stabilization and the
|
||||
//! single complete-Project semantic admission after merging these additions.
|
||||
use super::declarations::{self as d, Declaration, DeclarationKind};
|
||||
use super::*;
|
||||
use crate::analysis as a;
|
||||
use crate::analysis_numeric::Dimension;
|
||||
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Caller-assigned file key; spans are original offsets within that file.
|
||||
pub(super) struct Unit {
|
||||
pub file: usize,
|
||||
pub declarations: Vec<Declaration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Failure {
|
||||
/// None denotes an invalid ordinary skeleton, before typed resolution.
|
||||
pub file: Option<usize>,
|
||||
pub error: Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Category {
|
||||
Project,
|
||||
Element(Kind, Layer),
|
||||
Relation,
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
QuantityKind,
|
||||
Unit,
|
||||
Calculation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum Target {
|
||||
Global(Id),
|
||||
Parameter {
|
||||
calculation: Id,
|
||||
parameter: a::ParameterId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum Field {
|
||||
Algebra,
|
||||
Kind,
|
||||
Input(a::ParameterId),
|
||||
Result,
|
||||
Body,
|
||||
Subject,
|
||||
Type,
|
||||
Base,
|
||||
DisplayUnit,
|
||||
DocumentedConstraint,
|
||||
Predicate,
|
||||
Override(Id),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum Role {
|
||||
Reference,
|
||||
ParameterDeclaration,
|
||||
ParameterUse,
|
||||
CallTarget,
|
||||
ArgumentLabel,
|
||||
OverrideTarget,
|
||||
}
|
||||
|
||||
/// Stable structural edges, independent of alias spelling and argument order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum Child {
|
||||
Item(usize),
|
||||
Operand,
|
||||
Left,
|
||||
Right,
|
||||
Condition,
|
||||
WhenTrue,
|
||||
WhenFalse,
|
||||
Argument(a::ParameterId),
|
||||
Values,
|
||||
Initial,
|
||||
Base,
|
||||
Value,
|
||||
}
|
||||
|
||||
/// An arena index is only local to this lowering result. Follow parent edges
|
||||
/// to obtain the owner/field/path identity for S3; never persist the index.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct ExpressionSite {
|
||||
pub file: usize,
|
||||
pub owner: Id,
|
||||
pub field: Field,
|
||||
pub parent: Option<(usize, Child)>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Occurrence {
|
||||
pub file: usize,
|
||||
pub owner: Id,
|
||||
pub field: Field,
|
||||
pub expression: Option<usize>,
|
||||
pub role: Role,
|
||||
pub target: Target,
|
||||
/// Alias/name token only, excluding @/$ and surrounding trivia.
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Lowered {
|
||||
pub definitions: BTreeMap<Id, a::Definition>,
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
pub occurrences: Vec<Occurrence>,
|
||||
pub expressions: Vec<ExpressionSite>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Symbol {
|
||||
id: Id,
|
||||
category: Category,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Tables {
|
||||
aliases: BTreeMap<String, Symbol>,
|
||||
ids: BTreeSet<Id>,
|
||||
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
|
||||
entries: usize,
|
||||
}
|
||||
|
||||
fn category(kind: DeclarationKind) -> Category {
|
||||
match kind {
|
||||
DeclarationKind::QuantityKind => Category::QuantityKind,
|
||||
DeclarationKind::Unit => Category::Unit,
|
||||
DeclarationKind::Calculation => Category::Calculation,
|
||||
DeclarationKind::Property(layer) => Category::Element(Kind::EngineeringProperty, layer),
|
||||
DeclarationKind::Check(layer) => Category::Element(Kind::AnalysisCheck, layer),
|
||||
DeclarationKind::Case(layer) => Category::Element(Kind::AnalysisCase, layer),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(identity: &d::IdentitySyntax, span: Span) -> Result<Id, Error> {
|
||||
identity.value.ok_or_else(|| {
|
||||
error(
|
||||
span,
|
||||
"Missing ID requires source stabilization before lowering.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn reserve(&mut self, span: Span, budget: &Budget) -> Result<(), Error> {
|
||||
budget.lowering_entries(self.entries + 1, span)?;
|
||||
self.entries += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn insert(
|
||||
&mut self,
|
||||
id: Id,
|
||||
alias: &str,
|
||||
category: Category,
|
||||
span: Span,
|
||||
budget: &Budget,
|
||||
) -> Result<(), Error> {
|
||||
// A phase allocation bound, not a second syntax charge. Ordinary
|
||||
// records, typed headers and scoped parameters share this bound.
|
||||
self.reserve(span, budget)?;
|
||||
if self.ids.contains(&id) {
|
||||
return Err(error(span, "Duplicate global identity."));
|
||||
}
|
||||
if self.aliases.contains_key(alias) {
|
||||
return Err(error(span, format!("Duplicate global alias '{alias}'.")));
|
||||
}
|
||||
self.ids.insert(id);
|
||||
self.aliases
|
||||
.insert(alias.to_owned(), Symbol { id, category });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> {
|
||||
let at = Span::default();
|
||||
self.insert(project.id, &project.alias, Category::Project, at, budget)?;
|
||||
for value in project.definitions.values() {
|
||||
let category = match &value.body {
|
||||
a::DefinitionBody::QuantityKind(_) => Category::QuantityKind,
|
||||
a::DefinitionBody::Unit(_) => Category::Unit,
|
||||
a::DefinitionBody::Calculation(_) => Category::Calculation,
|
||||
};
|
||||
self.insert(value.id, &value.alias, category, at, budget)?;
|
||||
if let a::DefinitionBody::Calculation(calculation) = &value.body {
|
||||
let mut names = BTreeMap::new();
|
||||
let mut ids = BTreeSet::new();
|
||||
for parameter in &calculation.parameters {
|
||||
self.reserve(at, budget)?;
|
||||
if names.contains_key(¶meter.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(¶meter.identity, parameter.name.span)?);
|
||||
if names.contains_key(¶meter.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][¶meter.name.name];
|
||||
self.occurrence(
|
||||
¶meter.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(¶meter.value_type, Field::Input(id))?,
|
||||
});
|
||||
}
|
||||
Some(a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: lowered,
|
||||
result: self.value_type(&result.value, Field::Result)?,
|
||||
body: self.binding(&body.value, Field::Body, Some(self.owner))?,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let description = declaration
|
||||
.description
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |field| field.value.clone());
|
||||
if let Some(body) = definition {
|
||||
self.output.definitions.insert(
|
||||
self.owner,
|
||||
a::Definition {
|
||||
id: self.owner,
|
||||
alias: header.alias.name.clone(),
|
||||
name: header.name.clone(),
|
||||
description,
|
||||
body,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let (layer, body) = match (&declaration.body, header.kind) {
|
||||
(
|
||||
d::Body::Property {
|
||||
subject,
|
||||
value_type,
|
||||
base,
|
||||
display_unit,
|
||||
},
|
||||
DeclarationKind::Property(layer),
|
||||
) => (
|
||||
layer,
|
||||
SharedBody::EngineeringProperty {
|
||||
subject: self.subject(&subject.value)?,
|
||||
value_type: self.value_type(&value_type.value, Field::Type)?,
|
||||
base: self.binding(&base.value, Field::Base, None)?,
|
||||
display_unit: display_unit
|
||||
.as_ref()
|
||||
.and_then(|field| field.value.as_ref())
|
||||
.map(|reference| {
|
||||
self.definition_ref(reference, Field::DisplayUnit, None, Category::Unit)
|
||||
})
|
||||
.transpose()?,
|
||||
},
|
||||
),
|
||||
(
|
||||
d::Body::Check {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
},
|
||||
DeclarationKind::Check(layer),
|
||||
) => (
|
||||
layer,
|
||||
SharedBody::AnalysisCheck {
|
||||
subject: self.optional_subject(subject)?,
|
||||
documented_constraint: documented_constraint
|
||||
.as_ref()
|
||||
.and_then(|field| field.value.as_ref())
|
||||
.map(|reference| {
|
||||
self.global(
|
||||
reference,
|
||||
Field::DocumentedConstraint,
|
||||
None,
|
||||
Role::Reference,
|
||||
|category| {
|
||||
matches!(category, Category::Element(Kind::Constraint, _))
|
||||
},
|
||||
)
|
||||
.map(|symbol| symbol.id)
|
||||
})
|
||||
.transpose()?,
|
||||
predicate: self.binding(&predicate.value, Field::Predicate, None)?,
|
||||
},
|
||||
),
|
||||
(
|
||||
d::Body::Case {
|
||||
subject, overrides, ..
|
||||
},
|
||||
DeclarationKind::Case(layer),
|
||||
) => {
|
||||
let subject = self.optional_subject(subject)?;
|
||||
let mut lowered = BTreeMap::new();
|
||||
for entry in &overrides.value {
|
||||
let property = self
|
||||
.global(
|
||||
&entry.target,
|
||||
Field::Base,
|
||||
None,
|
||||
Role::OverrideTarget,
|
||||
|category| {
|
||||
matches!(category, Category::Element(Kind::EngineeringProperty, _))
|
||||
},
|
||||
)?
|
||||
.id;
|
||||
// Replace the placeholder field after resolution so the key
|
||||
// and its binding share their stable override identity.
|
||||
self.output
|
||||
.occurrences
|
||||
.last_mut()
|
||||
.expect("override occurrence")
|
||||
.field = Field::Override(property);
|
||||
if lowered.contains_key(&property) {
|
||||
return Err(error(
|
||||
entry.target.span,
|
||||
"Duplicate resolved override target.",
|
||||
));
|
||||
}
|
||||
self.budget
|
||||
.lowering_entries(lowered.len() + 1, entry.span)?;
|
||||
lowered.insert(
|
||||
property,
|
||||
self.binding(&entry.binding, Field::Override(property), None)?,
|
||||
);
|
||||
}
|
||||
(
|
||||
layer,
|
||||
SharedBody::AnalysisCase {
|
||||
subject,
|
||||
base: a::BaselineBase::ModelBaseline,
|
||||
overrides: lowered,
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
header.kind_span,
|
||||
"Typed declaration header/body mismatch.",
|
||||
));
|
||||
}
|
||||
};
|
||||
self.output.elements.insert(
|
||||
self.owner,
|
||||
Element {
|
||||
id: self.owner,
|
||||
alias: header.alias.name.clone(),
|
||||
name: header.name.clone(),
|
||||
description,
|
||||
parent: None,
|
||||
body: ElementBody::shared_in(layer, body),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn binding(
|
||||
&mut self,
|
||||
binding: &Binding,
|
||||
field: Field,
|
||||
calculation: Option<Id>,
|
||||
) -> Result<a::Binding, Error> {
|
||||
match binding {
|
||||
Binding::Unbound(_) => Ok(a::Binding::Unbound),
|
||||
Binding::Expression(expression) => self
|
||||
.expression(expression, field, calculation)
|
||||
.map(a::Binding::Expression),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(
|
||||
&mut self,
|
||||
definition: &Reference,
|
||||
arguments: &[Argument],
|
||||
field: Field,
|
||||
site: usize,
|
||||
) -> Result<Call, Error> {
|
||||
let calculation = self
|
||||
.global(
|
||||
definition,
|
||||
field,
|
||||
Some(site),
|
||||
Role::CallTarget,
|
||||
|category| category == Category::Calculation,
|
||||
)?
|
||||
.id;
|
||||
let tables = self.tables;
|
||||
let signature = &tables.parameters[&calculation];
|
||||
let mut parameters = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for argument in arguments {
|
||||
self.budget
|
||||
.lowering_entries(parameters.len() + 1, argument.parameter.span)?;
|
||||
let parameter = *signature.get(&argument.parameter.name).ok_or_else(|| {
|
||||
error(
|
||||
argument.parameter.span,
|
||||
"Unknown argument name in called calculation.",
|
||||
)
|
||||
})?;
|
||||
if !seen.insert(parameter) {
|
||||
return Err(error(
|
||||
argument.parameter.span,
|
||||
"Duplicate resolved argument parameter.",
|
||||
));
|
||||
}
|
||||
self.occurrence(
|
||||
&argument.parameter,
|
||||
field,
|
||||
Some(site),
|
||||
Role::ArgumentLabel,
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
},
|
||||
)?;
|
||||
parameters.push(parameter);
|
||||
}
|
||||
// Argument-set/type checking remains in existing semantic admission.
|
||||
Ok(Call {
|
||||
calculation,
|
||||
parameters,
|
||||
})
|
||||
}
|
||||
|
||||
fn expression(
|
||||
&mut self,
|
||||
root: &Expression,
|
||||
field: Field,
|
||||
calculation: Option<Id>,
|
||||
) -> Result<a::Expr, Error> {
|
||||
enum Work<'a> {
|
||||
Visit(&'a Expression, Option<(usize, Child)>),
|
||||
Build(&'a Expression, usize, Option<Call>, usize),
|
||||
}
|
||||
let mut work = vec![Work::Visit(root, None)];
|
||||
let mut values = Vec::new();
|
||||
while let Some(task) = work.pop() {
|
||||
match task {
|
||||
Work::Visit(expression, parent) => {
|
||||
self.budget
|
||||
.lowering_entries(self.output.expressions.len() + 1, expression.span)?;
|
||||
let site = self.output.expressions.len();
|
||||
self.output.expressions.push(ExpressionSite {
|
||||
file: self.file,
|
||||
owner: self.owner,
|
||||
field,
|
||||
parent,
|
||||
span: expression.span,
|
||||
});
|
||||
let call = if let ExpressionKind::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} = &expression.kind
|
||||
{
|
||||
Some(self.call(definition, arguments, field, site)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let child_count = match &expression.kind {
|
||||
ExpressionKind::List(items) => items.len(),
|
||||
ExpressionKind::Call { arguments, .. } => arguments.len(),
|
||||
ExpressionKind::Unary { .. }
|
||||
| ExpressionKind::Power { .. }
|
||||
| ExpressionKind::AsKind { .. } => 1,
|
||||
ExpressionKind::Binary { .. } | ExpressionKind::Sum { .. } => 2,
|
||||
ExpressionKind::If { .. } => 3,
|
||||
_ => 0,
|
||||
};
|
||||
self.budget
|
||||
.lowering_entries(work.len() + child_count + 1, expression.span)?;
|
||||
let mut children = Vec::with_capacity(child_count);
|
||||
match &expression.kind {
|
||||
ExpressionKind::List(items) => children.extend(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| (child, Child::Item(index))),
|
||||
),
|
||||
ExpressionKind::Unary { operand, .. } => {
|
||||
children.push((operand.as_ref(), Child::Operand))
|
||||
}
|
||||
ExpressionKind::Binary { left, right, .. } => children
|
||||
.extend([(left.as_ref(), Child::Left), (right.as_ref(), Child::Right)]),
|
||||
ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => children.extend([
|
||||
(condition.as_ref(), Child::Condition),
|
||||
(when_true.as_ref(), Child::WhenTrue),
|
||||
(when_false.as_ref(), Child::WhenFalse),
|
||||
]),
|
||||
ExpressionKind::Call { arguments, .. } => children.extend(
|
||||
arguments
|
||||
.iter()
|
||||
.zip(&call.as_ref().expect("call signature").parameters)
|
||||
.map(|(argument, id)| (&argument.value, Child::Argument(*id))),
|
||||
),
|
||||
ExpressionKind::Sum { values, initial } => children.extend([
|
||||
(values.as_ref(), Child::Values),
|
||||
(initial.as_ref(), Child::Initial),
|
||||
]),
|
||||
ExpressionKind::Power { base, .. } => {
|
||||
children.push((base.as_ref(), Child::Base))
|
||||
}
|
||||
ExpressionKind::AsKind { value, .. } => {
|
||||
children.push((value.as_ref(), Child::Value))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
work.push(Work::Build(expression, site, call, children.len()));
|
||||
work.extend(
|
||||
children
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|(child, edge)| Work::Visit(child, Some((site, edge)))),
|
||||
);
|
||||
}
|
||||
Work::Build(expression, site, call, count) => {
|
||||
let start = values.len() - count;
|
||||
let mut children = values.split_off(start).into_iter();
|
||||
let next = |children: &mut std::vec::IntoIter<a::Expr>| {
|
||||
Box::new(children.next().expect("visited expression child"))
|
||||
};
|
||||
use ExpressionKind as E;
|
||||
let value = match &expression.kind {
|
||||
E::Boolean(value) => a::Expr::Boolean(*value),
|
||||
E::Number(value) => a::Expr::Number(value.clone()),
|
||||
E::Quantity { magnitude, unit } => a::Expr::Quantity {
|
||||
magnitude: magnitude.clone(),
|
||||
unit: self.definition_ref(unit, field, Some(site), Category::Unit)?,
|
||||
},
|
||||
E::Property(reference) => a::Expr::Property(
|
||||
self.global(
|
||||
reference,
|
||||
field,
|
||||
Some(site),
|
||||
Role::Reference,
|
||||
|category| {
|
||||
matches!(
|
||||
category,
|
||||
Category::Element(Kind::EngineeringProperty, _)
|
||||
)
|
||||
},
|
||||
)?
|
||||
.id,
|
||||
),
|
||||
E::Parameter(reference) => {
|
||||
let calculation = calculation.ok_or_else(|| {
|
||||
error(
|
||||
reference.span,
|
||||
"Parameter use is only valid in its calculation body.",
|
||||
)
|
||||
})?;
|
||||
let parameter = *self.tables.parameters[&calculation]
|
||||
.get(&reference.name)
|
||||
.ok_or_else(|| {
|
||||
error(
|
||||
reference.span,
|
||||
"Unknown parameter in owning calculation.",
|
||||
)
|
||||
})?;
|
||||
self.occurrence(
|
||||
reference,
|
||||
field,
|
||||
Some(site),
|
||||
Role::ParameterUse,
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
},
|
||||
)?;
|
||||
a::Expr::Parameter(parameter)
|
||||
}
|
||||
E::List(_) => a::Expr::List(children.collect()),
|
||||
E::Unary { op, .. } => a::Expr::Unary {
|
||||
op: *op,
|
||||
operand: next(&mut children),
|
||||
},
|
||||
E::Binary { op, .. } => a::Expr::Binary {
|
||||
op: *op,
|
||||
left: next(&mut children),
|
||||
right: next(&mut children),
|
||||
},
|
||||
E::If { .. } => a::Expr::If {
|
||||
condition: next(&mut children),
|
||||
when_true: next(&mut children),
|
||||
when_false: next(&mut children),
|
||||
},
|
||||
E::Call { .. } => {
|
||||
let call = call.expect("resolved call");
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(call.calculation),
|
||||
arguments: call.parameters.into_iter().zip(children).collect(),
|
||||
}
|
||||
}
|
||||
E::Sum { .. } => a::Expr::Sum {
|
||||
values: next(&mut children),
|
||||
initial: next(&mut children),
|
||||
},
|
||||
E::Power { exponent, .. } => a::Expr::Power {
|
||||
base: next(&mut children),
|
||||
exponent: *exponent,
|
||||
},
|
||||
E::AsKind { kind, .. } => a::Expr::AsKind {
|
||||
kind: self.definition_ref(
|
||||
kind,
|
||||
field,
|
||||
Some(site),
|
||||
Category::QuantityKind,
|
||||
)?,
|
||||
value: next(&mut children),
|
||||
},
|
||||
};
|
||||
self.budget
|
||||
.lowering_entries(values.len() + 1, expression.span)?;
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(values.pop().expect("one lowered expression"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Call {
|
||||
calculation: Id,
|
||||
parameters: Vec<a::ParameterId>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
|
||||
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
|
||||
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
|
||||
sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {}
|
||||
sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
|
||||
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
|
||||
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
|
||||
subject @receive; type Quantity(@duration); display_unit @millisecond;
|
||||
base 750 [@millisecond];
|
||||
}
|
||||
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
|
||||
subject @dispatch; type Quantity(@duration); display_unit @second;
|
||||
base 1 [@second];
|
||||
}
|
||||
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base @combine(a: @receive_time, b: @dispatch_time);
|
||||
}
|
||||
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base 2 [@second];
|
||||
}
|
||||
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
|
||||
subject none; documented_constraint none; predicate @total_time <= @limit;
|
||||
}
|
||||
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = 1250 [@millisecond]; };
|
||||
}
|
||||
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = unbound; };
|
||||
}
|
||||
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}
|
||||
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
|
||||
kind @duration; scale 1; offset 0; symbol "s";
|
||||
}
|
||||
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
|
||||
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
|
||||
}
|
||||
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time 1; }; algebra Linear;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
|
||||
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
|
||||
sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" {
|
||||
subject @receive; type Quantity(@duration); display_unit @millisecond;
|
||||
base 750 [@millisecond];
|
||||
}
|
||||
sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" {
|
||||
subject @dispatch; type Quantity(@duration); display_unit @second;
|
||||
base 1 [@second];
|
||||
}
|
||||
sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base @combine(a: @receive_time, b: @dispatch_time);
|
||||
}
|
||||
sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base 2 [@second];
|
||||
}
|
||||
sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" {
|
||||
subject none; documented_constraint none; predicate @total_time <= @limit;
|
||||
}
|
||||
sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = 1250 [@millisecond]; };
|
||||
}
|
||||
sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = unbound; };
|
||||
}
|
||||
calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}
|
||||
unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
|
||||
kind @duration; scale 1; offset 0; symbol "s";
|
||||
}
|
||||
unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" {
|
||||
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
|
||||
}
|
||||
quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time 1; }; algebra Linear;
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use crate::language::analysis_source::{budget::Limits, cursor::Cursor};
|
||||
|
||||
use super::super::model_oracle as oracle;
|
||||
|
||||
const OA: &str = include_str!("fixtures/typed-oa.arcadia");
|
||||
const SA: &str = include_str!("fixtures/typed-sa.arcadia");
|
||||
const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia");
|
||||
const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia");
|
||||
|
||||
fn budget() -> Budget {
|
||||
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||
}
|
||||
fn id(text: &str) -> Id {
|
||||
Id::parse_str(text).unwrap()
|
||||
}
|
||||
fn by_alias(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn ordinary(source: &str) -> Project {
|
||||
crate::language::parse(source).unwrap()
|
||||
}
|
||||
fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit {
|
||||
let tokens = lex::tokens(source, budget).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut declarations = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
declarations.push(d::parse(&mut cursor, 8, None).unwrap());
|
||||
}
|
||||
Unit { file, declarations }
|
||||
}
|
||||
fn merge(mut ordinary: Project, lowered: Lowered) -> Project {
|
||||
ordinary.definitions.extend(lowered.definitions);
|
||||
ordinary.elements.extend(lowered.elements);
|
||||
ordinary
|
||||
}
|
||||
fn admit(project: &Project) {
|
||||
a::admit(project, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
}
|
||||
fn failure(source: &str, ordinary: &Project) -> Failure {
|
||||
let mut budget = budget();
|
||||
let unit = unit(source, 7, &mut budget);
|
||||
lower(ordinary, &[unit], &budget).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() {
|
||||
for (layer, ordinary_source, typed_source) in
|
||||
[(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)]
|
||||
{
|
||||
let expected = oracle::project(layer);
|
||||
assert_eq!(expected.name, "Response budget");
|
||||
assert_eq!(expected.identities().len(), 15); // two additional scoped IDs
|
||||
for (alias, name) in oracle::NAMES {
|
||||
let object = by_alias(&expected, alias);
|
||||
let actual_name = expected
|
||||
.definitions
|
||||
.get(&object)
|
||||
.map(|value| value.name.as_str())
|
||||
.or_else(|| {
|
||||
expected
|
||||
.elements
|
||||
.get(&object)
|
||||
.map(|value| value.name.as_str())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(actual_name, name);
|
||||
assert_ne!(actual_name, alias);
|
||||
}
|
||||
let mut budget = budget();
|
||||
let units = [unit(typed_source, 17, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let numeric = budget.numeric_units();
|
||||
let skeleton = ordinary(ordinary_source);
|
||||
let first = lower(&skeleton, &units, &budget).unwrap();
|
||||
let second = lower(&skeleton, &units, &budget).unwrap();
|
||||
assert_eq!(first.occurrences, second.occurrences);
|
||||
assert_eq!(first.expressions, second.expressions);
|
||||
assert_eq!(budget.usage, usage);
|
||||
assert_eq!(budget.numeric_units(), numeric);
|
||||
assert_eq!(
|
||||
first
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|occurrence| occurrence.role != Role::ParameterDeclaration)
|
||||
.count(),
|
||||
usage.references
|
||||
);
|
||||
let actual = merge(skeleton, first);
|
||||
assert_eq!(actual, expected);
|
||||
admit(&actual); // the complete model's one semantic admission
|
||||
}
|
||||
assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() {
|
||||
let source = OA.replace('\n', "\r\n");
|
||||
let split = source.find("calculation combine").unwrap();
|
||||
let sources = [&source[..split], &source[split..]];
|
||||
let mut budget = budget();
|
||||
let mut units = [
|
||||
unit(sources[0], 4, &mut budget),
|
||||
unit(sources[1], 9, &mut budget),
|
||||
];
|
||||
let usage = budget.usage;
|
||||
assert_eq!(usage.bytes, source.len());
|
||||
let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
units.reverse();
|
||||
// This second order uses the same bounded lowerer; output models must not
|
||||
// depend on declaration/file traversal order. It is not an include parser.
|
||||
let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
assert_eq!(
|
||||
merge(ordinary(ORDINARY_OA), reordered),
|
||||
oracle::project(Layer::Oa)
|
||||
);
|
||||
for occurrence in &lowered.occurrences {
|
||||
let source = if occurrence.file == 4 {
|
||||
sources[0]
|
||||
} else {
|
||||
assert_eq!(occurrence.file, 9);
|
||||
sources[1]
|
||||
};
|
||||
let text = &source[occurrence.span.start..occurrence.span.end];
|
||||
assert!(crate::valid_alias(text));
|
||||
assert!(!text.starts_with('@') && !text.starts_with('$'));
|
||||
}
|
||||
assert_eq!(budget.usage, usage);
|
||||
assert_eq!(
|
||||
merge(ordinary(ORDINARY_OA), lowered),
|
||||
oracle::project(Layer::Oa)
|
||||
);
|
||||
}
|
||||
|
||||
fn number(value: i64) -> a::Expr {
|
||||
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value))
|
||||
}
|
||||
fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr {
|
||||
a::Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() {
|
||||
let mut project = oracle::project(Layer::Oa);
|
||||
let duration = by_alias(&project, "duration");
|
||||
let second = by_alias(&project, "second");
|
||||
let property = by_alias(&project, "receive_time");
|
||||
let owner = by_alias(&project, "total_time");
|
||||
let calculation = by_alias(&project, "combine");
|
||||
let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
|
||||
let quantity =
|
||||
a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration)));
|
||||
let numeric = a::AnalysisType::Scalar(a::ScalarType::Number);
|
||||
let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean);
|
||||
let cases = [
|
||||
("true", a::Expr::Boolean(true), boolean.clone()),
|
||||
(
|
||||
"ratio(3, 2)",
|
||||
a::Expr::Number(
|
||||
Exact::from_wire(
|
||||
&crate::analysis_numeric::ExactWire {
|
||||
numerator: "3".into(),
|
||||
denominator: "2".into(),
|
||||
},
|
||||
&mut crate::analysis_numeric::WorkMeter::new(
|
||||
crate::analysis_numeric::WorkLimits::REFERENCE,
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"2 [@second]",
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(2),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
(
|
||||
"@receive_time",
|
||||
a::Expr::Property(property),
|
||||
quantity.clone(),
|
||||
),
|
||||
("$a", a::Expr::Parameter(parameter), quantity.clone()),
|
||||
(
|
||||
"[1, 2]",
|
||||
a::Expr::List(vec![number(1), number(2)]),
|
||||
a::AnalysisType::List(a::ScalarType::Number),
|
||||
),
|
||||
(
|
||||
"abs(-2)",
|
||||
a::Expr::Unary {
|
||||
op: a::UnaryOp::Absolute,
|
||||
operand: Box::new(a::Expr::Unary {
|
||||
op: a::UnaryOp::Negate,
|
||||
operand: Box::new(number(2)),
|
||||
}),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"1 + 2",
|
||||
binary(a::BinaryOp::Add, number(1), number(2)),
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"if(true, 1, 2)",
|
||||
a::Expr::If {
|
||||
condition: Box::new(a::Expr::Boolean(true)),
|
||||
when_true: Box::new(number(1)),
|
||||
when_false: Box::new(number(2)),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"@combine(b: 2 [@second], a: 1 [@second])",
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(calculation),
|
||||
arguments: BTreeMap::from([
|
||||
(
|
||||
parameter,
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
),
|
||||
(
|
||||
b,
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(2),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
(
|
||||
"sum([1, 2], 0)",
|
||||
a::Expr::Sum {
|
||||
values: Box::new(a::Expr::List(vec![number(1), number(2)])),
|
||||
initial: Box::new(number(0)),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"power(2, -2)",
|
||||
a::Expr::Power {
|
||||
base: Box::new(number(2)),
|
||||
exponent: -2,
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
|
||||
a::Expr::AsKind {
|
||||
kind: a::DefinitionRef::Local(duration),
|
||||
value: Box::new(binary(
|
||||
a::BinaryOp::Divide,
|
||||
a::Expr::Power {
|
||||
base: Box::new(a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
}),
|
||||
exponent: 2,
|
||||
},
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
)),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
];
|
||||
// Remove the original check's dependent quantity comparison: each case
|
||||
// installs a different declared result type on total_time below.
|
||||
project.elements.remove(&by_alias(&project, "budget"));
|
||||
for (source, expected, result_type) in cases {
|
||||
let mut budget = budget();
|
||||
let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else {
|
||||
panic!("expression")
|
||||
};
|
||||
let mut tables = Tables::default();
|
||||
tables.ordinary(&project, &budget).unwrap();
|
||||
let mut output = Lowered {
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
occurrences: Vec::new(),
|
||||
expressions: Vec::new(),
|
||||
};
|
||||
let is_parameter = source == "$a";
|
||||
let expression_owner = if is_parameter { calculation } else { owner };
|
||||
let actual = Resolver {
|
||||
tables: &tables,
|
||||
output: &mut output,
|
||||
budget: &budget,
|
||||
file: 3,
|
||||
owner: expression_owner,
|
||||
}
|
||||
.expression(
|
||||
&syntax,
|
||||
if is_parameter {
|
||||
Field::Body
|
||||
} else {
|
||||
Field::Base
|
||||
},
|
||||
is_parameter.then_some(calculation),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected, "{source}");
|
||||
assert_eq!(output.expressions[0].span, syntax.span);
|
||||
assert!(output.expressions[0].parent.is_none());
|
||||
let mut candidate = project.clone();
|
||||
if is_parameter {
|
||||
let a::DefinitionBody::Calculation(definition) =
|
||||
&mut candidate.definitions.get_mut(&calculation).unwrap().body
|
||||
else {
|
||||
panic!("calculation")
|
||||
};
|
||||
definition.body = a::Binding::Expression(actual);
|
||||
} else {
|
||||
let SharedBody::EngineeringProperty {
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
..
|
||||
} = candidate
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("property")
|
||||
};
|
||||
*value_type = result_type;
|
||||
*display_unit = None;
|
||||
*base = a::Binding::Expression(actual);
|
||||
}
|
||||
admit(&candidate);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() {
|
||||
let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, // first\n b: @dispatch_time) + @combine(// second\n b: @dispatch_time, a: @receive_time);") + r#"
|
||||
calculation other "Other" id "00000000-0000-0000-0000-000000000020" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number;
|
||||
result Number; body $a;
|
||||
}
|
||||
quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; }
|
||||
calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" {
|
||||
input a id "00000000-0000-0000-0000-000000000022" : Number;
|
||||
result Number; body @other(a: $a) + @other(a: 3);
|
||||
}
|
||||
"#;
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 2, &mut budget)];
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
|
||||
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
|
||||
let labels: Vec<_> = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|value| value.owner == total && value.role == Role::ArgumentLabel)
|
||||
.collect();
|
||||
assert_eq!(labels.len(), 4);
|
||||
assert_eq!(
|
||||
labels
|
||||
.iter()
|
||||
.map(|value| value.span.start)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
let sites: BTreeSet<_> = labels
|
||||
.iter()
|
||||
.map(|value| value.expression.unwrap())
|
||||
.collect();
|
||||
assert_eq!(sites.len(), 2);
|
||||
let paths: Vec<_> = sites
|
||||
.iter()
|
||||
.map(|site| output.expressions[*site].parent.unwrap().1)
|
||||
.collect();
|
||||
assert_eq!(paths, [Child::Left, Child::Right]);
|
||||
for label in labels {
|
||||
assert_eq!(label.field, Field::Base);
|
||||
assert!(
|
||||
matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine)
|
||||
);
|
||||
assert!(matches!(
|
||||
&source[label.span.start..label.span.end],
|
||||
"a" | "b"
|
||||
));
|
||||
}
|
||||
let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let scopes: BTreeSet<_> = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter_map(|occurrence| match occurrence.target {
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
} if parameter == reused => Some(calculation),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
scopes,
|
||||
BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")])
|
||||
);
|
||||
let caller = id("00000000-0000-0000-0000-000000000021");
|
||||
let use_in_caller = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|value| value.owner == caller && value.role == Role::ParameterUse)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
use_in_caller.target,
|
||||
Target::Parameter {
|
||||
calculation: caller,
|
||||
parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022"))
|
||||
}
|
||||
);
|
||||
for site in &output.expressions {
|
||||
assert_eq!(site.file, 2);
|
||||
assert!(source.get(site.span.start..site.span.end).is_some());
|
||||
}
|
||||
admit(&merge(ordinary(ORDINARY_OA), output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_ids_require_stabilization_without_uuid_or_budget_churn() {
|
||||
for source in [
|
||||
r#"calculation pending "Pending" { result Number; body unbound; }"#,
|
||||
r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#,
|
||||
] {
|
||||
let mut budget = budget();
|
||||
let units = [unit(source, 3, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let skeleton = ordinary(ORDINARY_OA);
|
||||
let first = lower(&skeleton, &units, &budget).unwrap_err();
|
||||
assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err());
|
||||
assert!(first.error.message.contains("stabilization"));
|
||||
assert_eq!(budget.usage, usage);
|
||||
let declaration = &units[0].declarations[0];
|
||||
if declaration.header.identity.value.is_none() {
|
||||
assert!(declaration.header.identity.insertion.is_some());
|
||||
} else {
|
||||
let d::Body::Calculation { parameters, .. } = &declaration.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
assert!(parameters[0].identity.value.is_none());
|
||||
assert!(parameters[0].identity.insertion.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_global_aliases_and_ids_fail_before_body_resolution() {
|
||||
let ordinary = ordinary(ORDINARY_OA);
|
||||
for (source, message) in [
|
||||
(
|
||||
OA.replace("quantity_kind duration", "quantity_kind respond"),
|
||||
"Duplicate global alias",
|
||||
),
|
||||
(
|
||||
OA.replace(
|
||||
"188b911c-5f8b-431c-9eea-2d41f3953767",
|
||||
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
|
||||
),
|
||||
"Duplicate global identity",
|
||||
),
|
||||
(format!("{OA}\n{OA}"), "Duplicate global"),
|
||||
] {
|
||||
let failed = failure(&source, &ordinary);
|
||||
assert_eq!(failed.file, Some(7));
|
||||
assert!(failed.error.message.contains(message), "{:?}", failed);
|
||||
assert!(!failed.error.message.contains("Unknown"));
|
||||
}
|
||||
let mut budget = budget();
|
||||
let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)];
|
||||
let failed = lower(&ordinary, &units, &budget).unwrap_err();
|
||||
assert_eq!(failed.file, Some(5));
|
||||
assert!(failed.error.message.contains("Duplicate global identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() {
|
||||
let skeleton = ordinary(ORDINARY_OA);
|
||||
for (old, new, token, message) in [
|
||||
(
|
||||
"subject @receive",
|
||||
"subject @second",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"Quantity(@duration)",
|
||||
"Quantity(@second)",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"display_unit @second",
|
||||
"display_unit @duration",
|
||||
"duration",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"750 [@millisecond]",
|
||||
"750 [@duration]",
|
||||
"duration",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"base 1 [@second]",
|
||||
"base @respond",
|
||||
"respond",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@combine(a:",
|
||||
"@second(a:",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@combine(a:",
|
||||
"@combine(unknown:",
|
||||
"unknown",
|
||||
"Unknown argument",
|
||||
),
|
||||
(
|
||||
"body $a + $b",
|
||||
"body $outside + $b",
|
||||
"outside",
|
||||
"Unknown parameter",
|
||||
),
|
||||
(
|
||||
"base 1 [@second]",
|
||||
"base $a",
|
||||
"a",
|
||||
"only valid in its calculation",
|
||||
),
|
||||
(
|
||||
"subject @receive",
|
||||
"subject @missing",
|
||||
"missing",
|
||||
"Unknown global",
|
||||
),
|
||||
(
|
||||
"documented_constraint none",
|
||||
"documented_constraint @respond",
|
||||
"respond",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@receive_time = unbound",
|
||||
"@second = unbound",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
] {
|
||||
let source = OA.replace(old, new);
|
||||
assert_ne!(source, OA, "mutation {old}");
|
||||
let failed = failure(&source, &skeleton);
|
||||
assert!(
|
||||
failed.error.message.contains(message),
|
||||
"{new}: {:?}",
|
||||
failed
|
||||
);
|
||||
assert_eq!(
|
||||
&source[failed.error.span.start..failed.error.span.end],
|
||||
token
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() {
|
||||
let mut skeleton = ordinary(ORDINARY_OA);
|
||||
let respond = by_alias(&skeleton, "respond");
|
||||
let receive = by_alias(&skeleton, "receive");
|
||||
let relation = Id::from_u128(100);
|
||||
skeleton.relations.insert(
|
||||
relation,
|
||||
crate::Relation {
|
||||
id: relation,
|
||||
alias: "link".into(),
|
||||
name: "Link".into(),
|
||||
description: String::new(),
|
||||
body: crate::RelationBody::new(
|
||||
crate::RelationKind::FunctionalExchange,
|
||||
respond,
|
||||
receive,
|
||||
),
|
||||
},
|
||||
);
|
||||
let diagram = skeleton.ensure_diagram(Layer::Oa);
|
||||
skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into();
|
||||
let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. });
|
||||
skeleton
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = "placement".into();
|
||||
let artifact = Id::from_u128(101);
|
||||
skeleton.artifact_links.insert(
|
||||
artifact,
|
||||
crate::ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "document".into(),
|
||||
name: "Document".into(),
|
||||
description: String::new(),
|
||||
owner: respond,
|
||||
relation: crate::ArtifactRelation::References,
|
||||
uri: "urn:test".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let trace = Id::from_u128(102);
|
||||
skeleton.traces.insert(
|
||||
trace,
|
||||
crate::Trace {
|
||||
id: trace,
|
||||
alias: "mapping".into(),
|
||||
name: "Mapping".into(),
|
||||
description: String::new(),
|
||||
oa: respond,
|
||||
sa: receive,
|
||||
kind: crate::TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
// Resolution classification is independent of whole-model admission; the
|
||||
// synthetic trace is deliberately not asserted to be a valid OA/SA trace.
|
||||
let budget = budget();
|
||||
let mut tables = Tables::default();
|
||||
tables.ordinary(&skeleton, &budget).unwrap();
|
||||
for (alias, category) in [
|
||||
("response_budget", Category::Project),
|
||||
("link", Category::Relation),
|
||||
("view", Category::Diagram),
|
||||
("placement", Category::Node),
|
||||
("document", Category::Artifact),
|
||||
("mapping", Category::Trace),
|
||||
] {
|
||||
assert_eq!(tables.aliases[alias].category, category);
|
||||
let failed = failure(
|
||||
&OA.replace("base 1 [@second]", &format!("base @{alias}")),
|
||||
&skeleton,
|
||||
);
|
||||
assert!(
|
||||
failed.error.message.contains("Wrong reference domain"),
|
||||
"{:?}",
|
||||
failed
|
||||
);
|
||||
}
|
||||
let source = OA.replace("subject @receive", "subject @link");
|
||||
let mut budget = self::budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let output = lower(&skeleton, &units, &budget).unwrap();
|
||||
let property = output
|
||||
.elements
|
||||
.values()
|
||||
.find(|value| value.alias == "receive_time")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() {
|
||||
for (old, new) in [
|
||||
("base 750 [@millisecond]", "base true"),
|
||||
(
|
||||
"base 750 [@millisecond]",
|
||||
"base as_kind(@duration, 1 [@second])",
|
||||
),
|
||||
("body $a + $b", "body @receive_time"),
|
||||
("a: @receive_time, b: @dispatch_time", "a: @receive_time"),
|
||||
("oa_property receive_time", "sa_property receive_time"),
|
||||
("base 1 [@second]", "base @dispatch_time"),
|
||||
] {
|
||||
let source = OA.replace(old, new);
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
let project = merge(ordinary(ORDINARY_OA), output);
|
||||
assert!(
|
||||
a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(),
|
||||
"{new}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() {
|
||||
let ordinary = oracle::project(Layer::Oa);
|
||||
for (maximum, passes) in [(0, false), (16, false), (17, true)] {
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = maximum;
|
||||
let budget = Budget::new(limits).unwrap();
|
||||
let result = lower(&ordinary, &[], &budget);
|
||||
assert_eq!(result.is_ok(), passes, "entry bound {maximum}");
|
||||
if let Err(failure) = result {
|
||||
assert!(failure.error.message.contains("lowering entry limit"));
|
||||
assert_eq!(failure.file, None);
|
||||
}
|
||||
assert_eq!(budget.usage, budget::Usage::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() {
|
||||
let source = r#"
|
||||
unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" {
|
||||
kind @point; scale 1; offset ratio(27315, 100); symbol "°C";
|
||||
}
|
||||
quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" {
|
||||
dimension { temperature 1; }; algebra Affine(@interval);
|
||||
}
|
||||
quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" {
|
||||
dimension { temperature 1; }; algebra Linear;
|
||||
}
|
||||
calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" {
|
||||
input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval));
|
||||
result List(Quantity(@interval)); body unbound; description "保留";
|
||||
}
|
||||
calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" {
|
||||
result Boolean; body unbound;
|
||||
}
|
||||
oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" {
|
||||
subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none;
|
||||
}
|
||||
oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" {
|
||||
subject @respond; documented_constraint @requirement; predicate unbound;
|
||||
}
|
||||
oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" {
|
||||
subject @respond; base ModelBaseline; overrides { @ambient = unbound; };
|
||||
}
|
||||
"#;
|
||||
let mut skeleton = ordinary(ORDINARY_OA);
|
||||
let requirement = Id::from_u128(70);
|
||||
skeleton.elements.insert(
|
||||
requirement,
|
||||
Element {
|
||||
id: requirement,
|
||||
alias: "requirement".into(),
|
||||
name: "Requirement".into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Constraint {
|
||||
expression: String::new(),
|
||||
constrained: Vec::new(),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
let mut budget = budget();
|
||||
let units = [unit(source, 8, &mut budget)];
|
||||
let output = lower(&skeleton, &units, &budget).unwrap();
|
||||
assert_eq!(output.definitions.len(), 5);
|
||||
let point = id("00000000-0000-0000-0000-000000000062");
|
||||
let interval = id("00000000-0000-0000-0000-000000000063");
|
||||
let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else {
|
||||
panic!("quantity kind")
|
||||
};
|
||||
assert_eq!(
|
||||
kind.algebra,
|
||||
a::QuantityAlgebra::Affine {
|
||||
difference: a::DefinitionRef::Local(interval)
|
||||
}
|
||||
);
|
||||
let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")];
|
||||
assert_eq!(unused.description, "保留");
|
||||
let a::DefinitionBody::Calculation(calculation) = &unused.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
assert_eq!(calculation.body, a::Binding::Unbound);
|
||||
assert_eq!(
|
||||
calculation.parameters[0].value_type,
|
||||
a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval)))
|
||||
);
|
||||
assert_eq!(calculation.result, calculation.parameters[0].value_type);
|
||||
assert!(
|
||||
output
|
||||
.occurrences
|
||||
.iter()
|
||||
.any(|occurrence| occurrence.field == Field::DocumentedConstraint
|
||||
&& occurrence.target == Target::Global(requirement))
|
||||
);
|
||||
let override_id = id("00000000-0000-0000-0000-000000000067");
|
||||
assert!(output.occurrences.iter().any(|occurrence| occurrence.field
|
||||
== Field::Override(override_id)
|
||||
&& occurrence.role == Role::OverrideTarget));
|
||||
assert!(
|
||||
output
|
||||
.occurrences
|
||||
.iter()
|
||||
.any(|occurrence| occurrence.owner == point
|
||||
&& occurrence.field == Field::Algebra
|
||||
&& occurrence.target == Target::Global(interval))
|
||||
);
|
||||
admit(&merge(skeleton, output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() {
|
||||
let mut budget = budget();
|
||||
let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)];
|
||||
let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err();
|
||||
assert_eq!(failed.file, Some(3));
|
||||
assert!(failed.error.message.contains("Duplicate source file"));
|
||||
let failure = budget
|
||||
.bytes(usize::MAX, Span { start: 5, end: 6 })
|
||||
.unwrap_err();
|
||||
let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err();
|
||||
assert_eq!(failed.error, failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() {
|
||||
let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127));
|
||||
let wide = std::iter::repeat_n("1", 1024)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let source = format!(
|
||||
r#"
|
||||
calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }}
|
||||
calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }}
|
||||
"#
|
||||
);
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
assert_eq!(output.expressions.len(), 128 + 1025);
|
||||
let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body
|
||||
else {
|
||||
panic!("deep calculation")
|
||||
};
|
||||
let a::Binding::Expression(root) = &deep.body else {
|
||||
panic!("deep expression")
|
||||
};
|
||||
let mut current = root;
|
||||
let mut depth = 1;
|
||||
while let a::Expr::Unary { operand, .. } = current {
|
||||
depth += 1;
|
||||
current = operand;
|
||||
}
|
||||
assert_eq!(depth, 128);
|
||||
assert_eq!(current, &number(1));
|
||||
let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body
|
||||
else {
|
||||
panic!("wide calculation")
|
||||
};
|
||||
let a::Binding::Expression(a::Expr::List(values)) = &wide.body else {
|
||||
panic!("wide list")
|
||||
};
|
||||
assert_eq!(values.len(), 1024);
|
||||
assert!(values.iter().all(|value| *value == number(1)));
|
||||
assert_eq!(budget.usage, usage);
|
||||
admit(&merge(ordinary(ORDINARY_OA), output));
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Private syntax-only complete-bundle witness. No model admission or edit authority.
|
||||
//!
|
||||
//! One scan charges original bytes and raw tokens (including trivia). Typed
|
||||
//! syntax keeps its existing entry/reference/numeric charges. Ordinary syntax
|
||||
//! charges one entry per project/declaration/include/description, field, and
|
||||
//! value before allocation; array values already account for their list slots.
|
||||
//! These private storage ceilings are independent of caller model record counts.
|
||||
//! Ordinary references consume value storage, not ordered-model-step allowance.
|
||||
//! Typed reference occurrences retain the caller max_step_references ceiling.
|
||||
//! Only declaration graph/record counts are known here; semantic nested-model
|
||||
//! records and ordinary ordered steps still require the later complete admission.
|
||||
//! Metadata phase slots aggregate across all files: retained token-end indices,
|
||||
//! value-end entries, projected value/field/header/declaration positions, lexical
|
||||
//! owner slots and the file table. Checks do not recharge syntax or latch errors;
|
||||
//! every failure must return immediately. Tokens themselves use the token bound.
|
||||
//! This deliberately bounded staging policy is not calibrated public source8.
|
||||
|
||||
use super::{
|
||||
budget::{Budget, Limits},
|
||||
cursor::Cursor,
|
||||
declarations, lex,
|
||||
};
|
||||
use crate::Id;
|
||||
use crate::language::{
|
||||
self, Ast, EditDeclaration, Error, Lexeme, ParseLimits, Parser, SourceUnit, Span, TokenClass,
|
||||
};
|
||||
use crate::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic, traversal::Traversal};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct ModelCounts {
|
||||
limits: ProjectLimits,
|
||||
graph: usize,
|
||||
records: usize,
|
||||
includes: usize,
|
||||
}
|
||||
|
||||
pub(super) struct ParsedMixedSyntax<'s> {
|
||||
bundle: &'s SourceBundle,
|
||||
// Preserve caller policy for the later semantic pass. Syntax storage uses
|
||||
// separate ceilings and cannot stand in for these graph/record/step limits.
|
||||
limits: ProjectLimits,
|
||||
files: Vec<FileSyntax<'s>>,
|
||||
budget: Budget,
|
||||
}
|
||||
|
||||
struct FileSyntax<'s> {
|
||||
path: &'s str,
|
||||
source: &'s str,
|
||||
tokens: Vec<lex::Token>,
|
||||
ordinary: SourceUnit,
|
||||
typed: Vec<declarations::Declaration>,
|
||||
positions: Vec<(Id, EditDeclaration)>,
|
||||
// A lexical owner is an occurrence index, not only a possibly repeated UUID.
|
||||
lexical_owners: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
// Only Parser borrows this state; the stored witness has no self-reference.
|
||||
pub(in crate::language) struct State<'a> {
|
||||
source: &'a str,
|
||||
tokens: &'a [lex::Token],
|
||||
budget: &'a mut Budget,
|
||||
model: &'a mut ModelCounts,
|
||||
typed: Vec<declarations::Declaration>,
|
||||
pub(in crate::language) value_ends: BTreeMap<usize, usize>,
|
||||
pub(in crate::language) lexical_owners: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
fn ordinary_error(error: super::Error) -> Error {
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: error.span.start,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
fn lexeme(token: lex::Token) -> Lexeme {
|
||||
Lexeme {
|
||||
class: match token.class {
|
||||
lex::Class::Word => TokenClass::Identifier,
|
||||
lex::Class::Number => TokenClass::Number,
|
||||
lex::Class::String => TokenClass::String,
|
||||
lex::Class::Punctuation => TokenClass::Punctuation,
|
||||
},
|
||||
span: token.span,
|
||||
}
|
||||
}
|
||||
|
||||
impl State<'_> {
|
||||
pub(in crate::language) fn token(&self, at: usize) -> Option<Lexeme> {
|
||||
self.tokens.get(at).copied().map(lexeme)
|
||||
}
|
||||
pub(in crate::language) fn entry(&mut self, at: Span) -> Result<(), Error> {
|
||||
self.budget.entry(at).map_err(ordinary_error)
|
||||
}
|
||||
pub(in crate::language) fn record(&mut self, graph: bool, at: Span) -> Result<(), Error> {
|
||||
let records = self.model.records.checked_add(1);
|
||||
let graph = self.model.graph.checked_add(usize::from(graph));
|
||||
let (Some(records), Some(graph)) = (records, graph) else {
|
||||
return Err(Error {
|
||||
code: "model_limit",
|
||||
offset: at.start,
|
||||
message: "Declaration count overflow.".into(),
|
||||
});
|
||||
};
|
||||
self.model
|
||||
.limits
|
||||
.model
|
||||
.check_counts(graph, records, 0)
|
||||
.map_err(|message| Error {
|
||||
code: "model_limit",
|
||||
offset: at.start,
|
||||
message,
|
||||
})?;
|
||||
self.model.records = records;
|
||||
self.model.graph = graph;
|
||||
Ok(())
|
||||
}
|
||||
pub(in crate::language) fn include(&mut self, at: Span) -> Result<(), Error> {
|
||||
if self.model.includes >= self.model.limits.max_include_edges {
|
||||
return Err(Error {
|
||||
code: "project_limit",
|
||||
offset: at.start,
|
||||
message: format!(
|
||||
"Include graph exceeds {} edges.",
|
||||
self.model.limits.max_include_edges
|
||||
),
|
||||
});
|
||||
}
|
||||
self.model.includes += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub(in crate::language) fn ordinary_number(
|
||||
&self,
|
||||
at: &mut usize,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
let first = self.tokens[*at];
|
||||
let mut end = first.span.end;
|
||||
let mut after = *at + 1;
|
||||
// Match the old greedy numeric alphabet only across byte-adjacent raw
|
||||
// tokens. This is consumption of the shared stream, not another scan.
|
||||
while let Some(next) = self.tokens.get(after) {
|
||||
if next.span.start != end
|
||||
|| !self.source[next.span.start..next.span.end]
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte))
|
||||
{
|
||||
break;
|
||||
}
|
||||
end = next.span.end;
|
||||
after += 1;
|
||||
}
|
||||
let value: serde_json::Number = serde_json::from_str(&self.source[first.span.start..end])
|
||||
.map_err(|_| Error {
|
||||
code: "invalid_source",
|
||||
offset: first.span.start,
|
||||
message: "Expected a byte-adjacent finite JSON decimal number.".into(),
|
||||
})?;
|
||||
*at = after;
|
||||
Ok(serde_json::Value::Number(value))
|
||||
}
|
||||
pub(in crate::language) fn typed_header(&self, at: usize, root: bool) -> bool {
|
||||
let Some(token) = self.tokens.get(at) else {
|
||||
return false;
|
||||
};
|
||||
if declarations::declaration_kind(&self.source[token.span.start..token.span.end]).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A generic field named `unit` remains an ordinary field. A declaration
|
||||
// header has an alias and display-name token; only that shape is nested.
|
||||
root || (self
|
||||
.tokens
|
||||
.get(at + 1)
|
||||
.is_some_and(|t| t.class == lex::Class::Word)
|
||||
&& self
|
||||
.tokens
|
||||
.get(at + 2)
|
||||
.is_some_and(|t| t.class == lex::Class::String))
|
||||
}
|
||||
pub(in crate::language) fn parse_typed(
|
||||
&mut self,
|
||||
at: &mut usize,
|
||||
owner: Option<Id>,
|
||||
) -> Result<(), Error> {
|
||||
// Transfer the one active position into the typed consumer and back;
|
||||
// neither consumer scans, clones a stream, nor runs concurrently.
|
||||
self.record(true, self.tokens[*at].span)?;
|
||||
let mut cursor = Cursor::new(self.source, self.tokens, self.budget);
|
||||
cursor.cursor = *at;
|
||||
let result = declarations::parse(&mut cursor, 8, owner);
|
||||
*at = cursor.cursor;
|
||||
let declaration = result.map_err(ordinary_error)?;
|
||||
let missing = declaration.header.identity.value.is_none()
|
||||
|| matches!(&declaration.body, declarations::Body::Calculation { parameters, .. }
|
||||
if parameters.iter().any(|p| p.identity.value.is_none()));
|
||||
if missing {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset: declaration.span.start,
|
||||
message: "Private mixed syntax requires explicit IDs.".into(),
|
||||
});
|
||||
}
|
||||
self.typed.push(declaration);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::language) fn long_analysis_word(word: &str) -> bool {
|
||||
matches!(
|
||||
word,
|
||||
"oa_engineering_property"
|
||||
| "sa_engineering_property"
|
||||
| "oa_analysis_check"
|
||||
| "sa_analysis_check"
|
||||
| "oa_analysis_case"
|
||||
| "sa_analysis_case"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn parse(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
|
||||
let profile = Limits::source8(ParseLimits {
|
||||
// Storage is not the caller's final model record/ordered-step accounting.
|
||||
max_source_bytes: limits.model.max_source_bytes,
|
||||
max_step_references: limits.model.max_step_references,
|
||||
..ParseLimits::UNLIMITED
|
||||
});
|
||||
parse_with_profile(bundle, limits, profile)
|
||||
}
|
||||
|
||||
fn located(path: &str, source: &str, error: Error) -> Vec<SourceDiagnostic> {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.into(),
|
||||
diagnostic: language::diagnostic(source, error),
|
||||
related: Vec::new(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn parse_with_profile(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
profile: Limits,
|
||||
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
|
||||
let entry_source = bundle.files.get(&bundle.entry).map_or("", String::as_str);
|
||||
let at_entry = |error| located(&bundle.entry, entry_source, ordinary_error(error));
|
||||
let maximum = Limits::source8(ParseLimits {
|
||||
max_source_bytes: limits.model.max_source_bytes,
|
||||
max_step_references: limits.model.max_step_references,
|
||||
..ParseLimits::UNLIMITED
|
||||
});
|
||||
if profile.bytes > maximum.bytes
|
||||
|| profile.tokens > maximum.tokens
|
||||
|| profile.entries > maximum.entries
|
||||
|| profile.references > maximum.references
|
||||
|| profile.frames > maximum.frames
|
||||
|| profile.numeric_units > maximum.numeric_units
|
||||
{
|
||||
return Err(at_entry(super::error(
|
||||
Span::default(),
|
||||
"Mixed test profile may only tighten inherited limits.",
|
||||
)));
|
||||
}
|
||||
let mut budget = Budget::new(profile).map_err(at_entry)?;
|
||||
let bytes = bundle
|
||||
.files
|
||||
.values()
|
||||
.try_fold(0usize, |n, s| n.checked_add(s.len()))
|
||||
.ok_or_else(|| at_entry(super::error(Span::default(), "Source size overflow.")))?;
|
||||
budget
|
||||
.index_bytes(bytes, Span::default())
|
||||
.map_err(at_entry)?;
|
||||
let mut metadata = bundle.files.len();
|
||||
budget
|
||||
.lowering_entries(metadata, Span::default())
|
||||
.map_err(at_entry)?;
|
||||
// Tighter private byte/file-slot limits precede folded-path table and
|
||||
// traversal allocations. Public Traversal's own validation order is intact.
|
||||
let mut traversal = Traversal::new(bundle, limits)?;
|
||||
let mut parsed = BTreeMap::new();
|
||||
let mut model = ModelCounts {
|
||||
limits,
|
||||
graph: 0,
|
||||
records: 0,
|
||||
includes: 0,
|
||||
};
|
||||
while let Some(path) = traversal.next()? {
|
||||
let (path, source) = bundle
|
||||
.files
|
||||
.get_key_value(&path)
|
||||
.expect("traversal checked source");
|
||||
let failure = |error| located(path, source, ordinary_error(error));
|
||||
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
|
||||
let mut parser = Parser {
|
||||
source,
|
||||
tokens: Vec::new(),
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits: limits.model,
|
||||
mixed: Some(State {
|
||||
source,
|
||||
tokens: &tokens,
|
||||
budget: &mut budget,
|
||||
model: &mut model,
|
||||
typed: Vec::new(),
|
||||
value_ends: BTreeMap::new(),
|
||||
lexical_owners: Vec::new(),
|
||||
}),
|
||||
};
|
||||
let ordinary = parser
|
||||
.unit(path == &bundle.entry, true)
|
||||
.map_err(|e| located(path, source, e))?;
|
||||
let state = parser.mixed.take().expect("private parser state");
|
||||
drop(parser);
|
||||
// Projected ordinary AST nodes/fields are bounded before constructing a
|
||||
// second position representation. Count references as slots, not syntax.
|
||||
let projection_slots = ordinary_position_slots(&ordinary);
|
||||
metadata = metadata
|
||||
.checked_add(tokens.len())
|
||||
.and_then(|n| n.checked_add(state.value_ends.len()))
|
||||
.and_then(|n| n.checked_add(projection_slots))
|
||||
.and_then(|n| n.checked_add(state.lexical_owners.len()))
|
||||
.ok_or_else(|| failure(super::error(Span::default(), "Metadata size overflow.")))?;
|
||||
state
|
||||
.budget
|
||||
.lowering_entries(metadata, Span::default())
|
||||
.map_err(failure)?;
|
||||
let positions = language::edit_positions_from_tokens(
|
||||
source,
|
||||
&ordinary,
|
||||
|at| lexeme(tokens[at]),
|
||||
tokens.len(),
|
||||
Some(&state.value_ends),
|
||||
)
|
||||
.map_err(|e| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *e,
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
for (raw, span) in &ordinary.includes {
|
||||
traversal.include(path, raw, *span)?;
|
||||
}
|
||||
traversal.finish_file(path.clone());
|
||||
let State {
|
||||
typed,
|
||||
lexical_owners,
|
||||
..
|
||||
} = state;
|
||||
parsed.insert(
|
||||
path.as_str(),
|
||||
FileSyntax {
|
||||
path,
|
||||
source,
|
||||
tokens,
|
||||
ordinary,
|
||||
typed,
|
||||
positions,
|
||||
lexical_owners,
|
||||
},
|
||||
);
|
||||
}
|
||||
traversal.finish()?;
|
||||
budget.active().map_err(at_entry)?;
|
||||
Ok(ParsedMixedSyntax {
|
||||
bundle,
|
||||
limits,
|
||||
files: parsed.into_values().collect(),
|
||||
budget,
|
||||
})
|
||||
}
|
||||
|
||||
fn ordinary_position_slots(unit: &SourceUnit) -> usize {
|
||||
// Iterative accounting avoids adding a second recursive walk before the
|
||||
// already depth-bounded position projection. Each reference is one value.
|
||||
let mut slots = 0usize;
|
||||
let mut stack = Vec::new();
|
||||
for declaration in &unit.declarations {
|
||||
slots = slots.saturating_add(7); // declaration/body + at most five header roles
|
||||
slots = slots.saturating_add(declaration.fields.len());
|
||||
stack.extend(declaration.fields.values());
|
||||
}
|
||||
while let Some(value) = stack.pop() {
|
||||
slots = slots.saturating_add(1);
|
||||
match value {
|
||||
Ast::Array(items, _) => stack.extend(items),
|
||||
Ast::Object(fields, _) => {
|
||||
slots = slots.saturating_add(fields.len());
|
||||
stack.extend(fields.values());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
//! Independent model construction: never parses source or calls the writer.
|
||||
use crate::analysis::*;
|
||||
use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter};
|
||||
use crate::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) const NAMES: [(&str, &str); 14] = [
|
||||
("duration", "Duration"),
|
||||
("second", "Second"),
|
||||
("millisecond", "Millisecond"),
|
||||
("combine", "Combine durations"),
|
||||
("respond", "Respond"),
|
||||
("receive", "Receive"),
|
||||
("dispatch", "Dispatch"),
|
||||
("receive_time", "Receive time"),
|
||||
("dispatch_time", "Dispatch time"),
|
||||
("total_time", "Total time"),
|
||||
("limit", "Allowed duration"),
|
||||
("budget", "Response budget"),
|
||||
("degraded", "Degraded radio"),
|
||||
("unmeasured", "Unmeasured radio"),
|
||||
];
|
||||
|
||||
fn named(alias: &str) -> String {
|
||||
NAMES
|
||||
.iter()
|
||||
.find(|(key, _)| *key == alias)
|
||||
.unwrap()
|
||||
.1
|
||||
.into()
|
||||
}
|
||||
fn local(id: Id) -> DefinitionRef {
|
||||
DefinitionRef::Local(id)
|
||||
}
|
||||
fn quantity(value: i64, unit: Id) -> Expr {
|
||||
Expr::Quantity {
|
||||
magnitude: Exact::from_integer(value),
|
||||
unit: local(unit),
|
||||
}
|
||||
}
|
||||
fn expression(value: Expr) -> Binding {
|
||||
Binding::Expression(value)
|
||||
}
|
||||
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
|
||||
Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn project(layer: Layer) -> Project {
|
||||
// OA is the fixed r2 set; SA independently constructs fresh global IDs.
|
||||
let id = |text| {
|
||||
let id = Id::parse_str(text).unwrap();
|
||||
if layer == Layer::Oa {
|
||||
id
|
||||
} else {
|
||||
Id::from_u128(id.as_u128() ^ (1 << 127))
|
||||
}
|
||||
};
|
||||
let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d");
|
||||
let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767");
|
||||
let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce");
|
||||
let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891");
|
||||
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
|
||||
// Parameter IDs may be reused in another calculation/project.
|
||||
let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap());
|
||||
let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap());
|
||||
let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59");
|
||||
let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353");
|
||||
let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b");
|
||||
let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450");
|
||||
let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace");
|
||||
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
|
||||
let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec");
|
||||
let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf");
|
||||
let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93");
|
||||
let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde");
|
||||
let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration)));
|
||||
let definition = |id, alias: &str, body| Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
body,
|
||||
};
|
||||
let shared = |id, alias: &str, body| Element {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::shared_in(layer, body),
|
||||
};
|
||||
let work = |id, alias: &str, parent| Element {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: if layer == Layer::Oa {
|
||||
ElementBody::Oa(OaBody::Activity)
|
||||
} else {
|
||||
ElementBody::Sa(SaBody::Function)
|
||||
},
|
||||
};
|
||||
let definitions = [
|
||||
definition(
|
||||
duration,
|
||||
"duration",
|
||||
DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: QuantityAlgebra::Linear,
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
second,
|
||||
"second",
|
||||
DefinitionBody::Unit(UnitDefinition {
|
||||
kind: local(duration),
|
||||
scale: Exact::from_integer(1),
|
||||
offset: Exact::from_integer(0),
|
||||
symbol: "s".into(),
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
millisecond,
|
||||
"millisecond",
|
||||
DefinitionBody::Unit(UnitDefinition {
|
||||
kind: local(duration),
|
||||
scale: Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "1000".into(),
|
||||
},
|
||||
&mut WorkMeter::new(WorkLimits::REFERENCE),
|
||||
)
|
||||
.unwrap(),
|
||||
offset: Exact::from_integer(0),
|
||||
symbol: "ms".into(),
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
combine,
|
||||
"combine",
|
||||
DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters: vec![
|
||||
Parameter {
|
||||
id: a,
|
||||
name: "a".into(),
|
||||
value_type: duration_type(),
|
||||
},
|
||||
Parameter {
|
||||
id: b,
|
||||
name: "b".into(),
|
||||
value_type: duration_type(),
|
||||
},
|
||||
],
|
||||
result: duration_type(),
|
||||
body: expression(binary(
|
||||
BinaryOp::Add,
|
||||
Expr::Parameter(a),
|
||||
Expr::Parameter(b),
|
||||
)),
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|definition| (definition.id, definition))
|
||||
.collect();
|
||||
let property = |id, alias, subject, display, base| {
|
||||
shared(
|
||||
id,
|
||||
alias,
|
||||
SharedBody::EngineeringProperty {
|
||||
subject: AnalysisSubject::Element(subject),
|
||||
value_type: duration_type(),
|
||||
display_unit: Some(local(display)),
|
||||
base: expression(base),
|
||||
},
|
||||
)
|
||||
};
|
||||
let case = |id, alias, binding| {
|
||||
shared(
|
||||
id,
|
||||
alias,
|
||||
SharedBody::AnalysisCase {
|
||||
subject: None,
|
||||
base: BaselineBase::ModelBaseline,
|
||||
overrides: BTreeMap::from([(receive_time, binding)]),
|
||||
},
|
||||
)
|
||||
};
|
||||
let elements = [
|
||||
work(respond, "respond", None),
|
||||
work(receive, "receive", Some(respond)),
|
||||
work(dispatch, "dispatch", Some(respond)),
|
||||
property(
|
||||
receive_time,
|
||||
"receive_time",
|
||||
receive,
|
||||
millisecond,
|
||||
quantity(750, millisecond),
|
||||
),
|
||||
property(
|
||||
dispatch_time,
|
||||
"dispatch_time",
|
||||
dispatch,
|
||||
second,
|
||||
quantity(1, second),
|
||||
),
|
||||
property(
|
||||
total,
|
||||
"total_time",
|
||||
respond,
|
||||
second,
|
||||
Expr::Call {
|
||||
definition: local(combine),
|
||||
arguments: BTreeMap::from([
|
||||
(a, Expr::Property(receive_time)),
|
||||
(b, Expr::Property(dispatch_time)),
|
||||
]),
|
||||
},
|
||||
),
|
||||
property(limit, "limit", respond, second, quantity(2, second)),
|
||||
shared(
|
||||
check,
|
||||
"budget",
|
||||
SharedBody::AnalysisCheck {
|
||||
subject: None,
|
||||
documented_constraint: None,
|
||||
predicate: expression(binary(
|
||||
BinaryOp::LessOrEqual,
|
||||
Expr::Property(total),
|
||||
Expr::Property(limit),
|
||||
)),
|
||||
},
|
||||
),
|
||||
case(
|
||||
degraded,
|
||||
"degraded",
|
||||
expression(quantity(1250, millisecond)),
|
||||
),
|
||||
case(unmeasured, "unmeasured", Binding::Unbound),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
Project {
|
||||
schema_version: 8,
|
||||
id: project_id,
|
||||
alias: "response_budget".into(),
|
||||
name: "Response budget".into(),
|
||||
description: String::new(),
|
||||
definitions,
|
||||
elements,
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Iterative Pratt machine. Source nesting does not recurse on the native stack.
|
||||
use super::cursor::Cursor;
|
||||
use super::*;
|
||||
use lex::Class;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
enum SequenceKind {
|
||||
List,
|
||||
If,
|
||||
Sum,
|
||||
Unary(UnaryOp),
|
||||
Call(Reference),
|
||||
}
|
||||
struct Sequence {
|
||||
kind: SequenceKind,
|
||||
start: usize,
|
||||
values: Vec<(Option<Reference>, Expression)>,
|
||||
keys: BTreeSet<String>,
|
||||
}
|
||||
enum Task {
|
||||
Expression(u8),
|
||||
Continue(u8),
|
||||
Binary(Expression, BinaryOp),
|
||||
Unary(usize, UnaryOp),
|
||||
Group(usize),
|
||||
Next(Sequence),
|
||||
Collected(Sequence, Option<Reference>),
|
||||
Power(usize),
|
||||
AsKind(usize, Reference),
|
||||
}
|
||||
pub(super) fn binding(p: &mut Cursor<'_, '_>) -> Result<Binding, Error> {
|
||||
p.budget.active()?;
|
||||
if p.text() == "unbound" {
|
||||
let span = p.span();
|
||||
p.cursor += 1;
|
||||
return Ok(Binding::Unbound(span));
|
||||
}
|
||||
let mut tasks = vec![Task::Expression(0)];
|
||||
let mut values = Vec::new();
|
||||
while let Some(task) = tasks.pop() {
|
||||
p.budget.active()?;
|
||||
match task {
|
||||
Task::Expression(minimum) => {
|
||||
let start = p.span().start;
|
||||
if p.text() != "(" {
|
||||
p.budget.entry(p.span())?;
|
||||
}
|
||||
tasks.push(Task::Continue(minimum));
|
||||
match p.text() {
|
||||
"true" | "false" => {
|
||||
let value = p.text() == "true";
|
||||
let span = p.span();
|
||||
p.cursor += 1;
|
||||
values.push(p.node(ExpressionKind::Boolean(value), span)?);
|
||||
}
|
||||
"-" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Unary(start, UnaryOp::Negate));
|
||||
tasks.push(Task::Expression(7));
|
||||
}
|
||||
"(" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Group(start));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
"[" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Next(Sequence::new(SequenceKind::List, start)));
|
||||
}
|
||||
"@" => {
|
||||
p.cursor += 1;
|
||||
let reference = p.reference()?;
|
||||
if p.text() == "(" {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Next(Sequence::new(
|
||||
SequenceKind::Call(reference),
|
||||
start,
|
||||
)));
|
||||
} else {
|
||||
let end = reference.span.end;
|
||||
values.push(
|
||||
p.node(ExpressionKind::Property(reference), Span { start, end })?,
|
||||
);
|
||||
}
|
||||
}
|
||||
"$" => {
|
||||
p.cursor += 1;
|
||||
let reference = p.reference()?;
|
||||
let end = reference.span.end;
|
||||
values.push(
|
||||
p.node(ExpressionKind::Parameter(reference), Span { start, end })?,
|
||||
);
|
||||
}
|
||||
"ratio" => {
|
||||
let (value, literal) = p.exact_literal()?;
|
||||
values.push(p.number(value, literal)?);
|
||||
}
|
||||
"abs" | "not" | "if" | "sum" => {
|
||||
let kind = match p.text() {
|
||||
"abs" => SequenceKind::Unary(UnaryOp::Absolute),
|
||||
"not" => SequenceKind::Unary(UnaryOp::Not),
|
||||
"if" => SequenceKind::If,
|
||||
_ => SequenceKind::Sum,
|
||||
};
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
tasks.push(Task::Next(Sequence::new(kind, start)));
|
||||
}
|
||||
"power" => {
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
tasks.push(Task::Power(start));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
"as_kind" => {
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
p.take("@")?;
|
||||
let kind = p.reference()?;
|
||||
p.take(",")?;
|
||||
tasks.push(Task::AsKind(start, kind));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
_ if p
|
||||
.tokens
|
||||
.get(p.cursor)
|
||||
.is_some_and(|token| token.class == Class::Number) =>
|
||||
{
|
||||
let (value, literal) = p.exact_literal()?;
|
||||
values.push(p.number(value, literal)?);
|
||||
}
|
||||
_ => return Err(error(p.span(), "Expected an analysis expression.")),
|
||||
}
|
||||
}
|
||||
Task::Continue(minimum) => {
|
||||
if let Some((op, precedence)) =
|
||||
operator(p.text()).filter(|(_, prec)| *prec >= minimum)
|
||||
{
|
||||
let left = values.pop().expect("completed Pratt operand");
|
||||
if comparison(op) && ungrouped_comparison(&left) {
|
||||
return Err(error(p.span(), "Comparison children require parentheses."));
|
||||
}
|
||||
p.budget.entry(p.span())?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Continue(minimum));
|
||||
tasks.push(Task::Binary(left, op));
|
||||
tasks.push(Task::Expression(precedence + 1));
|
||||
}
|
||||
}
|
||||
Task::Binary(left, op) => {
|
||||
let right = values.pop().expect("completed right operand");
|
||||
if comparison(op) && ungrouped_comparison(&right) {
|
||||
return Err(error(
|
||||
right.span,
|
||||
"Comparison children require parentheses.",
|
||||
));
|
||||
}
|
||||
let span = Span {
|
||||
start: left.span.start,
|
||||
end: right.span.end,
|
||||
};
|
||||
values.push(p.node(
|
||||
ExpressionKind::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
},
|
||||
span,
|
||||
)?);
|
||||
}
|
||||
Task::Unary(start, op) => {
|
||||
let operand = values.pop().expect("completed unary operand");
|
||||
let end = operand.span.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::Unary {
|
||||
op,
|
||||
operand: Box::new(operand),
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
Task::Group(start) => {
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
let value = values.last_mut().expect("completed grouped expression");
|
||||
value.grouped = true;
|
||||
value.span = Span { start, end };
|
||||
}
|
||||
Task::Next(mut sequence) => {
|
||||
let closing = if matches!(sequence.kind, SequenceKind::List) {
|
||||
"]"
|
||||
} else {
|
||||
")"
|
||||
};
|
||||
if p.text() == closing {
|
||||
let end = p.take(closing)?.end;
|
||||
p.close();
|
||||
values.push(p.sequence(sequence, end)?);
|
||||
} else {
|
||||
let count = sequence.values.len();
|
||||
let maximum = match sequence.kind {
|
||||
SequenceKind::List => crate::analysis::MAX_LIST_LENGTH,
|
||||
SequenceKind::Unary(_) => 1,
|
||||
SequenceKind::If => 3,
|
||||
SequenceKind::Sum => 2,
|
||||
SequenceKind::Call(_) => usize::MAX,
|
||||
};
|
||||
if count >= maximum {
|
||||
return Err(error(
|
||||
p.span(),
|
||||
"Too many list members or constructor arguments.",
|
||||
));
|
||||
}
|
||||
let key = if matches!(sequence.kind, SequenceKind::Call(_)) {
|
||||
p.budget.entry(p.span())?;
|
||||
let key = p.reference()?;
|
||||
if !sequence.keys.insert(key.name.clone()) {
|
||||
return Err(error(key.span, "Duplicate call argument name."));
|
||||
}
|
||||
p.take(":")?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tasks.push(Task::Collected(sequence, key));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
}
|
||||
Task::Collected(mut sequence, key) => {
|
||||
sequence
|
||||
.values
|
||||
.push((key, values.pop().expect("completed sequence value")));
|
||||
let closing = if matches!(sequence.kind, SequenceKind::List) {
|
||||
"]"
|
||||
} else {
|
||||
")"
|
||||
};
|
||||
if p.text() != closing {
|
||||
p.take(",")?;
|
||||
if p.text() == closing {
|
||||
return Err(error(p.span(), "Trailing comma is not an expression."));
|
||||
}
|
||||
}
|
||||
tasks.push(Task::Next(sequence));
|
||||
}
|
||||
Task::Power(start) => {
|
||||
let base = values.pop().expect("completed power base");
|
||||
p.take(",")?;
|
||||
let negative = if p.text() == "-" {
|
||||
p.cursor += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let span = p.integer()?;
|
||||
let magnitude = p.source[span.start..span.end]
|
||||
.parse::<u8>()
|
||||
.map_err(|_| error(span, "Power exponent is outside -16..16."))?;
|
||||
if magnitude > 16 {
|
||||
return Err(error(span, "Power exponent is outside -16..16."));
|
||||
}
|
||||
let exponent = if negative {
|
||||
-(magnitude as i8)
|
||||
} else {
|
||||
magnitude as i8
|
||||
};
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::Power {
|
||||
base: Box::new(base),
|
||||
exponent,
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
Task::AsKind(start, kind) => {
|
||||
let value = values.pop().expect("completed kind assertion value");
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::AsKind {
|
||||
kind,
|
||||
value: Box::new(value),
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Binding::Expression(
|
||||
values.pop().expect("one complete expression"),
|
||||
))
|
||||
}
|
||||
impl Sequence {
|
||||
fn new(kind: SequenceKind, start: usize) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
start,
|
||||
values: Vec::new(),
|
||||
keys: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Cursor<'_, '_> {
|
||||
fn number(&mut self, magnitude: Exact, literal: LiteralSyntax) -> Result<Expression, Error> {
|
||||
let Span { start, end } = literal.span;
|
||||
let mut expression = if self.text() == "[" {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("@")?;
|
||||
let unit = self.reference()?;
|
||||
let end = self.take("]")?.end;
|
||||
self.close();
|
||||
self.node(
|
||||
ExpressionKind::Quantity { magnitude, unit },
|
||||
Span { start, end },
|
||||
)
|
||||
} else {
|
||||
self.node(ExpressionKind::Number(magnitude), Span { start, end })
|
||||
}?;
|
||||
expression.literal = Some(literal);
|
||||
Ok(expression)
|
||||
}
|
||||
fn node(&mut self, kind: ExpressionKind, span: Span) -> Result<Expression, Error> {
|
||||
let child_depth = match &kind {
|
||||
ExpressionKind::Unary { operand, .. } => operand.depth,
|
||||
ExpressionKind::Binary { left, right, .. } => left.depth.max(right.depth),
|
||||
ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => condition.depth.max(when_true.depth).max(when_false.depth),
|
||||
ExpressionKind::List(values) => values.iter().map(|v| v.depth).max().unwrap_or(0),
|
||||
ExpressionKind::Call { arguments, .. } => {
|
||||
arguments.iter().map(|a| a.value.depth).max().unwrap_or(0)
|
||||
}
|
||||
ExpressionKind::Sum { values, initial } => values.depth.max(initial.depth),
|
||||
ExpressionKind::Power { base, .. } => base.depth,
|
||||
ExpressionKind::AsKind { value, .. } => value.depth,
|
||||
_ => 0,
|
||||
};
|
||||
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
|
||||
// The rejected parent still owns accepted, potentially depth-128
|
||||
// children. Recursive derived cleanup here runs beneath the parser
|
||||
// frames and can exhaust the bounded native stack before returning
|
||||
// the depth error. Consume those children with an explicit worklist.
|
||||
drop_rejected_kind(kind);
|
||||
return Err(error(span, "Expression logical depth exceeds 128."));
|
||||
}
|
||||
Ok(Expression {
|
||||
kind,
|
||||
span,
|
||||
depth: child_depth + 1,
|
||||
grouped: false,
|
||||
literal: None,
|
||||
})
|
||||
}
|
||||
fn sequence(&mut self, sequence: Sequence, end: usize) -> Result<Expression, Error> {
|
||||
let span = Span {
|
||||
start: sequence.start,
|
||||
end,
|
||||
};
|
||||
let mut values = sequence.values.into_iter();
|
||||
let mut next = || {
|
||||
values
|
||||
.next()
|
||||
.map(|(_, value)| Box::new(value))
|
||||
.ok_or_else(|| error(span, "Missing required constructor argument."))
|
||||
};
|
||||
let kind = match sequence.kind {
|
||||
SequenceKind::If => ExpressionKind::If {
|
||||
condition: next()?,
|
||||
when_true: next()?,
|
||||
when_false: next()?,
|
||||
},
|
||||
SequenceKind::Sum => ExpressionKind::Sum {
|
||||
values: next()?,
|
||||
initial: next()?,
|
||||
},
|
||||
SequenceKind::Unary(op) => ExpressionKind::Unary {
|
||||
op,
|
||||
operand: next()?,
|
||||
},
|
||||
SequenceKind::List => ExpressionKind::List(values.map(|(_, value)| value).collect()),
|
||||
SequenceKind::Call(definition) => ExpressionKind::Call {
|
||||
definition,
|
||||
arguments: values
|
||||
.map(|(parameter, value)| Argument {
|
||||
parameter: parameter.expect("parsed named argument"),
|
||||
value,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
self.node(kind, span)
|
||||
}
|
||||
}
|
||||
fn drop_rejected_kind(kind: ExpressionKind) {
|
||||
let mut pending = vec![kind];
|
||||
while let Some(kind) = pending.pop() {
|
||||
match kind {
|
||||
ExpressionKind::Unary { operand, .. } => pending.push(operand.kind),
|
||||
ExpressionKind::Binary { left, right, .. } => {
|
||||
pending.push(left.kind);
|
||||
pending.push(right.kind);
|
||||
}
|
||||
ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(condition.kind);
|
||||
pending.push(when_true.kind);
|
||||
pending.push(when_false.kind);
|
||||
}
|
||||
ExpressionKind::List(values) => pending.extend(values.into_iter().map(|v| v.kind)),
|
||||
ExpressionKind::Call { arguments, .. } => {
|
||||
pending.extend(arguments.into_iter().map(|a| a.value.kind));
|
||||
}
|
||||
ExpressionKind::Sum { values, initial } => {
|
||||
pending.push(values.kind);
|
||||
pending.push(initial.kind);
|
||||
}
|
||||
ExpressionKind::Power { base, .. } => pending.push(base.kind),
|
||||
ExpressionKind::AsKind { value, .. } => pending.push(value.kind),
|
||||
ExpressionKind::Boolean(_)
|
||||
| ExpressionKind::Number(_)
|
||||
| ExpressionKind::Quantity { .. }
|
||||
| ExpressionKind::Property(_)
|
||||
| ExpressionKind::Parameter(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn comparison(op: BinaryOp) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
BinaryOp::Equal
|
||||
| BinaryOp::NotEqual
|
||||
| BinaryOp::Less
|
||||
| BinaryOp::LessOrEqual
|
||||
| BinaryOp::Greater
|
||||
| BinaryOp::GreaterOrEqual
|
||||
)
|
||||
}
|
||||
fn ungrouped_comparison(value: &Expression) -> bool {
|
||||
!value.grouped && matches!(&value.kind, ExpressionKind::Binary { op, .. } if comparison(*op))
|
||||
}
|
||||
fn operator(text: &str) -> Option<(BinaryOp, u8)> {
|
||||
Some(match text {
|
||||
"or" => (BinaryOp::Or, 1),
|
||||
"and" => (BinaryOp::And, 2),
|
||||
"==" => (BinaryOp::Equal, 3),
|
||||
"!=" => (BinaryOp::NotEqual, 3),
|
||||
"<" => (BinaryOp::Less, 4),
|
||||
"<=" => (BinaryOp::LessOrEqual, 4),
|
||||
">" => (BinaryOp::Greater, 4),
|
||||
">=" => (BinaryOp::GreaterOrEqual, 4),
|
||||
"+" => (BinaryOp::Add, 5),
|
||||
"-" => (BinaryOp::Subtract, 5),
|
||||
"*" => (BinaryOp::Multiply, 6),
|
||||
"/" => (BinaryOp::Divide, 6),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use budget::{Budget, Limits};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn budget() -> Budget {
|
||||
Budget::new(limits()).unwrap()
|
||||
}
|
||||
fn expression(source: &str) -> Expression {
|
||||
match parse(source, &mut budget()).unwrap() {
|
||||
Binding::Expression(value) => value,
|
||||
Binding::Unbound(_) => panic!("expected expression"),
|
||||
}
|
||||
}
|
||||
fn number(value: &Expression) -> String {
|
||||
let ExpressionKind::Number(exact) = &value.kind else {
|
||||
panic!("expected Number");
|
||||
};
|
||||
exact.canonical_literal()
|
||||
}
|
||||
|
||||
fn binary_children(value: Expression, expected: BinaryOp) -> (Expression, Expression) {
|
||||
let ExpressionKind::Binary { op, left, right } = value.kind else {
|
||||
panic!("expected Binary {expected:?}");
|
||||
};
|
||||
assert_eq!(op, expected);
|
||||
(*left, *right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_precedence_and_grouping_have_independent_expected_child_topology() {
|
||||
let (one, product) = binary_children(expression("1 + 2 * 3"), BinaryOp::Add);
|
||||
assert_eq!(number(&one), "1");
|
||||
let (two, three) = binary_children(product, BinaryOp::Multiply);
|
||||
assert_eq!(
|
||||
(number(&two), number(&three)),
|
||||
("2".to_owned(), "3".to_owned())
|
||||
);
|
||||
|
||||
let (conjunction, last) =
|
||||
binary_children(expression("not(false) and true or false"), BinaryOp::Or);
|
||||
assert_eq!(last.kind, ExpressionKind::Boolean(false));
|
||||
let (negated, middle) = binary_children(conjunction, BinaryOp::And);
|
||||
assert_eq!(middle.kind, ExpressionKind::Boolean(true));
|
||||
let ExpressionKind::Unary { op, operand } = negated.kind else {
|
||||
panic!("expected Not");
|
||||
};
|
||||
assert_eq!(op, UnaryOp::Not);
|
||||
assert_eq!(operand.kind, ExpressionKind::Boolean(false));
|
||||
|
||||
let (negative, two) = binary_children(expression("-1 * 2"), BinaryOp::Multiply);
|
||||
assert_eq!(number(&two), "2");
|
||||
let ExpressionKind::Unary { op, operand } = negative.kind else {
|
||||
panic!("expected Negate");
|
||||
};
|
||||
assert_eq!(op, UnaryOp::Negate);
|
||||
assert_eq!(number(&operand), "1");
|
||||
|
||||
let (quotient, three) = binary_children(expression("1 / 2 * 3"), BinaryOp::Multiply);
|
||||
assert_eq!(number(&three), "3");
|
||||
let (one, two) = binary_children(quotient, BinaryOp::Divide);
|
||||
assert_eq!(
|
||||
(number(&one), number(&two)),
|
||||
("1".to_owned(), "2".to_owned())
|
||||
);
|
||||
|
||||
let (one, grouped) = binary_children(expression("1 + (2 + 3)"), BinaryOp::Add);
|
||||
assert_eq!(number(&one), "1");
|
||||
assert!(grouped.grouped);
|
||||
let (two, three) = binary_children(grouped, BinaryOp::Add);
|
||||
assert_eq!(
|
||||
(number(&two), number(&three)),
|
||||
("2".to_owned(), "3".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children() {
|
||||
let flat = |count: usize| vec!["true"; count].join(" and ");
|
||||
let deep = flat(128);
|
||||
let mut tree = expression(&deep);
|
||||
assert_eq!(tree.depth, 128);
|
||||
// Independently verify the complete left-associated shape, not just depth.
|
||||
for _ in 1..128 {
|
||||
let (left, right) = binary_children(tree, BinaryOp::And);
|
||||
assert_eq!(right.kind, ExpressionKind::Boolean(true));
|
||||
tree = left;
|
||||
}
|
||||
assert_eq!(tree.kind, ExpressionKind::Boolean(true));
|
||||
assert!(
|
||||
parse(&flat(129), &mut budget())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("logical depth")
|
||||
);
|
||||
|
||||
// The first argument is already a complete depth128 tree when parsing the
|
||||
// second fails. Refusal tears down the held child without building Sum.
|
||||
let malformed = format!("sum({deep}, @)");
|
||||
let failure = parse(&malformed, &mut budget()).unwrap_err();
|
||||
assert_eq!(
|
||||
failure.message,
|
||||
"Expected a valid reference or parameter name."
|
||||
);
|
||||
assert_eq!(&malformed[failure.span.start..failure.span.end], ")");
|
||||
assert_eq!(number(&expression("1")), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parent_shape_rejects_depth129_and_cleans_children_on_128k_stack() {
|
||||
std::thread::Builder::new()
|
||||
.name("analysis-syntax-rejection-128k".into())
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(|| {
|
||||
// Rotate through every recursive syntax shape, so the rejected
|
||||
// parent's complete child tree also exercises every cleanup arm.
|
||||
let wrap = |shape, child: &str| match shape {
|
||||
0 => format!("abs({child})"),
|
||||
1 => format!("({child}) + 1"),
|
||||
2 => format!("if(true, {child}, 0)"),
|
||||
3 => format!("[{child}]"),
|
||||
4 => format!("@f(a: {child})"),
|
||||
5 => format!("sum({child}, 0)"),
|
||||
6 => format!("power({child}, 1)"),
|
||||
7 => format!("as_kind(@k, {child})"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut deep = "1".to_owned();
|
||||
for layer in 0..127 {
|
||||
deep = wrap(layer % 8, &deep);
|
||||
}
|
||||
assert_eq!(expression(&deep).depth, 128);
|
||||
for shape in 0..8 {
|
||||
let source = wrap(shape, &deep);
|
||||
let failure = parse(&source, &mut budget()).unwrap_err();
|
||||
assert_eq!(failure.message, "Expression logical depth exceeds 128.");
|
||||
assert_eq!(
|
||||
failure.span,
|
||||
Span {
|
||||
start: 0,
|
||||
end: source.len()
|
||||
}
|
||||
);
|
||||
assert_eq!(number(&expression("1")), "1");
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
|
||||
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
|
||||
let parsed = expression(source);
|
||||
assert_eq!(
|
||||
parsed.span,
|
||||
Span {
|
||||
start: 0,
|
||||
end: source.len()
|
||||
}
|
||||
);
|
||||
let literal = parsed.literal.as_ref().unwrap();
|
||||
assert!(source[literal.span.start..literal.span.end].contains("// denominator"));
|
||||
assert_eq!(
|
||||
literal
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|span| &source[span.start..span.end])
|
||||
.collect::<Vec<_>>(),
|
||||
["ratio", "(", "-", "3", ",", "4", ")"]
|
||||
);
|
||||
let ExpressionKind::Quantity { magnitude, unit } = parsed.kind else {
|
||||
panic!("expected Quantity");
|
||||
};
|
||||
assert_eq!(magnitude.canonical_literal(), "ratio(-3, 4)");
|
||||
assert_eq!(unit.name, "millisecond");
|
||||
assert_eq!(&source[unit.span.start..unit.span.end], "millisecond");
|
||||
let mut renamed = source.to_owned();
|
||||
renamed.replace_range(unit.span.start..unit.span.end, "ms");
|
||||
assert_eq!(renamed, source.replacen("millisecond", "ms", 1));
|
||||
assert_eq!(
|
||||
number(&expression("ratio (1, // rationale\n 2)")),
|
||||
"ratio(1, 2)"
|
||||
);
|
||||
for invalid in [
|
||||
"1 e - 2",
|
||||
"1e // x\n -2",
|
||||
"1 . 2",
|
||||
"ratio(1 + 2, 3)",
|
||||
"ratio(1, -2)",
|
||||
"ratio(+1, 2)",
|
||||
"ratio(1.0, 2)",
|
||||
"ratio(1, 0)",
|
||||
] {
|
||||
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_numeric_lexing_preserves_exact_atoms_and_operator_boundaries() {
|
||||
let source = "1-2 1e-2 ratio(-1,2) -(1) <= >= == !=";
|
||||
let tokens = lex::tokens(source, &mut budget()).unwrap();
|
||||
let text: Vec<_> = tokens
|
||||
.iter()
|
||||
.map(|t| &source[t.span.start..t.span.end])
|
||||
.collect();
|
||||
assert_eq!(
|
||||
text,
|
||||
[
|
||||
"1", "-", "2", "1e-2", "ratio", "(", "-", "1", ",", "2", ")", "-", "(", "1", ")", "<=",
|
||||
">=", "==", "!="
|
||||
]
|
||||
);
|
||||
let huge = "9".repeat(400);
|
||||
assert_eq!(
|
||||
lex::tokens(&huge, &mut budget()).unwrap()[0].class,
|
||||
lex::Class::Number
|
||||
);
|
||||
assert_eq!(number(&expression(&huge)), huge);
|
||||
for invalid in ["1e", "1e+", "1_0", "0x1", "1.2.3"] {
|
||||
assert!(lex::tokens(invalid, &mut budget()).is_err(), "{invalid}");
|
||||
}
|
||||
let quoted = "\"quoted\\\" café\"";
|
||||
let tokens = lex::tokens(quoted, &mut budget()).unwrap();
|
||||
assert_eq!(tokens[0].class, lex::Class::String);
|
||||
assert_eq!("ed[tokens[0].span.start..tokens[0].span.end], quoted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_literals_precedence_and_comparison_children_keep_distinct_trees() {
|
||||
assert_eq!(number(&expression("ratio(-1, 1)")), "ratio(-1, 1)");
|
||||
let negative = expression("-(1)");
|
||||
assert!(matches!(
|
||||
negative.kind,
|
||||
ExpressionKind::Unary {
|
||||
op: UnaryOp::Negate,
|
||||
..
|
||||
}
|
||||
));
|
||||
let left = expression("(1 - 2) - 3");
|
||||
let right = expression("1 - (2 - 3)");
|
||||
let ExpressionKind::Binary {
|
||||
left, right: last, ..
|
||||
} = left.kind
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
assert!(matches!(
|
||||
left.kind,
|
||||
ExpressionKind::Binary {
|
||||
op: BinaryOp::Subtract,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(number(&last), "3");
|
||||
let ExpressionKind::Binary {
|
||||
left: first, right, ..
|
||||
} = right.kind
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(number(&first), "1");
|
||||
assert!(matches!(
|
||||
right.kind,
|
||||
ExpressionKind::Binary {
|
||||
op: BinaryOp::Subtract,
|
||||
..
|
||||
}
|
||||
));
|
||||
for valid in [
|
||||
"(1 < 2) == true",
|
||||
"(true == false) == true",
|
||||
"true == (1 < 2)",
|
||||
"1 + 2 * 3",
|
||||
"not(false) and true or false",
|
||||
] {
|
||||
parse(valid, &mut budget()).unwrap();
|
||||
}
|
||||
for invalid in [
|
||||
"1 < 2 == true",
|
||||
"true == false == true",
|
||||
"1 < 2 < 3",
|
||||
"true == 1 < 2",
|
||||
] {
|
||||
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_expression_shape_and_exact_constructor_arity_is_represented() {
|
||||
let tree = expression("if(true, sum([@p, $x], 0), as_kind(@duration, power(abs(-(2)), -2)))");
|
||||
let ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} = tree.kind
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(condition.kind, ExpressionKind::Boolean(true));
|
||||
let ExpressionKind::Sum { values, initial } = when_true.kind else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(number(&initial), "0");
|
||||
let ExpressionKind::List(values) = values.kind else {
|
||||
panic!();
|
||||
};
|
||||
assert!(matches!(&values[0].kind, ExpressionKind::Property(r) if r.name == "p"));
|
||||
assert!(matches!(&values[1].kind, ExpressionKind::Parameter(r) if r.name == "x"));
|
||||
let ExpressionKind::AsKind { kind, value } = when_false.kind else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(kind.name, "duration");
|
||||
assert!(matches!(
|
||||
value.kind,
|
||||
ExpressionKind::Power { exponent: -2, .. }
|
||||
));
|
||||
assert!(matches!(expression("[]").kind, ExpressionKind::List(v) if v.is_empty()));
|
||||
assert!(
|
||||
matches!(expression("@zero()").kind, ExpressionKind::Call { arguments, .. } if arguments.is_empty())
|
||||
);
|
||||
assert!(matches!(
|
||||
parse(" unbound // pending", &mut budget()).unwrap(),
|
||||
Binding::Unbound(_)
|
||||
));
|
||||
for invalid in [
|
||||
"sum([1])",
|
||||
"sum([1], 0, 2)",
|
||||
"if(true, 1)",
|
||||
"abs()",
|
||||
"not(true, false)",
|
||||
"power(2, 17)",
|
||||
"power(2, 1.0)",
|
||||
"as_kind(duration, 1)",
|
||||
"[1,]",
|
||||
"[1 2]",
|
||||
"(1+2)[@unit]",
|
||||
] {
|
||||
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_call_argument_occurrences_have_distinct_original_spans() {
|
||||
let source = "@combine(a: @p, b: 2) + @combine(a: @q, b: 4)";
|
||||
let tree = expression(source);
|
||||
let ExpressionKind::Binary { left, right, .. } = tree.kind else {
|
||||
panic!();
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
definition: first,
|
||||
arguments: a,
|
||||
} = left.kind
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
definition: second,
|
||||
arguments: b,
|
||||
} = right.kind
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(first.name, second.name);
|
||||
assert_ne!(first.span, second.span);
|
||||
assert_eq!(a[0].parameter.name, b[0].parameter.name);
|
||||
assert_ne!(a[0].parameter.span, b[0].parameter.span);
|
||||
for argument in a.iter().chain(&b) {
|
||||
assert_eq!(
|
||||
&source[argument.parameter.span.start..argument.parameter.span.end],
|
||||
argument.parameter.name
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
parse("@combine(a: 1, a: 2)", &mut budget())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("Duplicate")
|
||||
);
|
||||
assert!(parse("@combine(a: 1, b:)", &mut budget()).is_err());
|
||||
// Resolution to owner-qualified ParameterId and actual source rewrites are
|
||||
// deliberately a later stage; this oracle establishes unique syntax spans.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_aggregate_across_fragments_and_latch_numeric_exhaustion() {
|
||||
let mut first = budget();
|
||||
parse("ratio(1, 3)", &mut first).unwrap();
|
||||
let cost = first.numeric_units();
|
||||
assert!(cost > 0);
|
||||
let mut settings = limits();
|
||||
settings.numeric_units = cost;
|
||||
let mut shared = Budget::new(settings).unwrap();
|
||||
parse("ratio (1, // same numeric cost\n 3)", &mut shared).unwrap();
|
||||
assert_eq!(shared.numeric_units(), cost);
|
||||
let failure = parse("2", &mut shared).unwrap_err();
|
||||
let usage = shared.usage;
|
||||
assert_eq!(parse("unbound", &mut shared).unwrap_err(), failure);
|
||||
assert_eq!(shared.usage, usage);
|
||||
parse("2", &mut budget()).unwrap();
|
||||
|
||||
let mut settings = limits();
|
||||
settings.entries = 2;
|
||||
let mut shared = Budget::new(settings).unwrap();
|
||||
parse("true", &mut shared).unwrap();
|
||||
parse("false", &mut shared).unwrap();
|
||||
assert!(
|
||||
parse("true", &mut shared)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("entries")
|
||||
);
|
||||
assert_eq!(shared.usage.entries, 2);
|
||||
let mut settings = limits();
|
||||
settings.references = 1;
|
||||
let mut shared = Budget::new(settings).unwrap();
|
||||
parse("@one", &mut shared).unwrap();
|
||||
assert!(
|
||||
parse("$two", &mut shared)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("references")
|
||||
);
|
||||
assert_eq!(shared.usage.references, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_token_frame_logical_and_list_boundaries_have_nearby_successes() {
|
||||
let mut settings = limits();
|
||||
settings.bytes = 4;
|
||||
let mut shared = Budget::new(settings).unwrap();
|
||||
parse("true", &mut shared).unwrap();
|
||||
assert!(
|
||||
parse(" ", &mut shared)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("bytes")
|
||||
);
|
||||
assert_eq!(shared.usage.bytes, 4);
|
||||
let mut settings = limits();
|
||||
settings.tokens = 2;
|
||||
let mut shared = Budget::new(settings).unwrap();
|
||||
parse("true ", &mut shared).unwrap();
|
||||
assert!(
|
||||
parse("// note", &mut shared)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("tokens")
|
||||
);
|
||||
assert_eq!(shared.usage.tokens, 2);
|
||||
let nested = |depth: usize| format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
|
||||
let mut shared = budget();
|
||||
assert_eq!(
|
||||
match parse(&nested(256), &mut shared).unwrap() {
|
||||
Binding::Expression(e) => e.depth,
|
||||
_ => 0,
|
||||
},
|
||||
1
|
||||
);
|
||||
assert_eq!(shared.usage.deepest_frame, 256);
|
||||
assert!(
|
||||
parse(&nested(257), &mut budget())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("frames")
|
||||
);
|
||||
let unary = |depth: usize| format!("{}true{}", "not(".repeat(depth - 1), ")".repeat(depth - 1));
|
||||
assert_eq!(expression(&unary(128)).depth, 128);
|
||||
assert!(
|
||||
parse(&unary(129), &mut budget())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("logical depth")
|
||||
);
|
||||
let list = |count: usize| format!("[{}]", vec!["true"; count].join(","));
|
||||
parse(&list(1024), &mut budget()).unwrap();
|
||||
assert!(parse(&list(1025), &mut budget()).is_err());
|
||||
let lowered = Limits::source8(ParseLimits {
|
||||
max_source_bytes: 8,
|
||||
max_graph_objects: 3,
|
||||
max_records: 5,
|
||||
max_step_references: 2,
|
||||
});
|
||||
assert_eq!(
|
||||
(
|
||||
lowered.bytes,
|
||||
lowered.tokens,
|
||||
lowered.entries,
|
||||
lowered.references
|
||||
),
|
||||
(8, 8, 5, 2)
|
||||
);
|
||||
let mut above = limits();
|
||||
above.frames += 1;
|
||||
assert!(Budget::new(above).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_wide_and_deep_input_cleanup_does_not_poison_a_fresh_parse() {
|
||||
let source = "@f(a: if(true, [ratio (1, //why\n 2), @p], sum([1, 2], 0)), b: power(2, -3))";
|
||||
let tokens = lex::tokens(source, &mut budget()).unwrap();
|
||||
for token in tokens.iter().filter(|token| {
|
||||
matches!(
|
||||
&source[token.span.start..token.span.end],
|
||||
"(" | "[" | "," | ":" | "@"
|
||||
)
|
||||
}) {
|
||||
assert!(parse(&source[..token.span.end], &mut budget()).is_err());
|
||||
assert_eq!(number(&expression("ratio(2, 3)")), "ratio(2, 3)");
|
||||
}
|
||||
for malformed in [
|
||||
format!("{}true", "(".repeat(256)),
|
||||
format!("[{}", vec!["true"; 1024].join(",")),
|
||||
"-".repeat(10_000),
|
||||
] {
|
||||
assert!(parse(&malformed, &mut budget()).is_err());
|
||||
parse("true", &mut budget()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_source_headers_and_existing_numeric_token_controls_are_unchanged() {
|
||||
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
|
||||
for accepted in ["-1.5", "1e-2", "0", "-0"] {
|
||||
let tokens = crate::language::tokenize(accepted);
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0].class, crate::language::TokenClass::Number);
|
||||
assert_eq!(
|
||||
tokens[0].span,
|
||||
Span {
|
||||
start: 0,
|
||||
end: accepted.len()
|
||||
}
|
||||
);
|
||||
}
|
||||
for rejected in ["+1", "1_0", "0x1", "1e"] {
|
||||
assert!(
|
||||
crate::language::tokenize(rejected)
|
||||
.iter()
|
||||
.any(|t| t.class == crate::language::TokenClass::Error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Simultaneous typed token edits. This is a staged plan, not source acceptance:
|
||||
//! the bundle owner must merge ordinary edits, reparse/recompile the exact bytes,
|
||||
//! admit once and compare the complete intended Project before installation.
|
||||
use super::index::{Index, Location, OccurrenceKey, checked_source};
|
||||
use super::lower::Target;
|
||||
use super::{Budget, Error, Span, error};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Edit {
|
||||
key: OccurrenceKey,
|
||||
location: Location,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Plan<'i, 's> {
|
||||
index: &'i Index<'s>,
|
||||
names: BTreeMap<Target, String>,
|
||||
edits: Vec<Edit>,
|
||||
}
|
||||
impl<'i, 's> Plan<'i, 's> {
|
||||
/// All names are the desired final names. Validate the final namespaces,
|
||||
/// rather than applying one rename at a time: alias/parameter swaps are legal.
|
||||
pub fn renames(
|
||||
index: &'i Index<'s>,
|
||||
renames: &BTreeMap<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<Self, Error> {
|
||||
let plan = Self::prepare(index, renames, budget)?;
|
||||
plan.validate(index.sources(), budget)?;
|
||||
plan.output_lengths(index.sources(), budget)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn validate_names(
|
||||
index: &Index<'_>,
|
||||
renames: &BTreeMap<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<(), Error> {
|
||||
budget.active()?;
|
||||
budget.lowering_entries(renames.len(), Span::default())?;
|
||||
let mut bytes = 0usize;
|
||||
for (target, name) in renames {
|
||||
if !index.names().contains_key(target) {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Rename has no qualified source target.",
|
||||
));
|
||||
}
|
||||
if !crate::valid_alias(name) {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Invalid alias or scoped parameter name.",
|
||||
));
|
||||
}
|
||||
bytes = bytes
|
||||
.checked_add(name.len())
|
||||
.ok_or_else(|| error(Span::default(), "Rename size overflow."))?;
|
||||
budget.index_bytes(bytes, Span::default())?;
|
||||
}
|
||||
let mut final_names = BTreeSet::new();
|
||||
for (target, original) in index.names() {
|
||||
let name = renames.get(target).unwrap_or(original);
|
||||
let scope = match target {
|
||||
Target::Global(_) => None,
|
||||
Target::Parameter { calculation, .. } => Some(*calculation),
|
||||
};
|
||||
budget.lowering_entries(final_names.len() + 1, Span::default())?;
|
||||
if !final_names.insert((scope, name.as_str())) {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Rename creates a duplicate name in its namespace.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
index: &'i Index<'s>,
|
||||
renames: &BTreeMap<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<Self, Error> {
|
||||
// Validate record/name storage before copying any owned final names.
|
||||
Self::validate_names(index, renames, budget)?;
|
||||
let mut edits = Vec::new();
|
||||
for (key, location) in index.occurrences() {
|
||||
if renames
|
||||
.get(&key.target)
|
||||
.is_some_and(|name| index.names().get(&key.target) != Some(name))
|
||||
{
|
||||
budget.lowering_occurrences(edits.len() + 1, location.span)?;
|
||||
edits.push(Edit {
|
||||
key: key.clone(),
|
||||
location: *location,
|
||||
});
|
||||
}
|
||||
}
|
||||
edits.sort_by_key(|edit| {
|
||||
(
|
||||
edit.location.file,
|
||||
edit.location.span.start,
|
||||
edit.location.span.end,
|
||||
)
|
||||
});
|
||||
Ok(Self {
|
||||
index,
|
||||
names: renames.clone(),
|
||||
edits,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_count(&self) -> usize {
|
||||
self.edits.len()
|
||||
}
|
||||
|
||||
/// Recheck the complete original witness and complete rename intent.
|
||||
/// A changed unrelated file is stale too. This does not construct output.
|
||||
fn validate(&self, current: &BTreeMap<usize, String>, budget: &Budget) -> Result<(), Error> {
|
||||
budget.active()?;
|
||||
if current != self.index.sources() {
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Source index is stale: original file set or bytes changed.",
|
||||
));
|
||||
}
|
||||
budget.lowering_entries(current.len(), Span::default())?;
|
||||
budget.lowering_occurrences(self.edits.len(), Span::default())?;
|
||||
let mut previous: Option<Location> = None;
|
||||
let mut seen = BTreeSet::new();
|
||||
for edit in &self.edits {
|
||||
let at = edit.location;
|
||||
if self.index.occurrences().get(&edit.key) != Some(&at) || !seen.insert(&edit.key) {
|
||||
return Err(error(at.span, "Forged or duplicate edit occurrence."));
|
||||
}
|
||||
let old = checked_source(current, at)?;
|
||||
if self
|
||||
.index
|
||||
.names()
|
||||
.get(&edit.key.target)
|
||||
.is_none_or(|name| name != old)
|
||||
{
|
||||
return Err(error(at.span, "Stale original token spelling."));
|
||||
}
|
||||
self.names
|
||||
.get(&edit.key.target)
|
||||
.ok_or_else(|| error(at.span, "Edit has no final target name."))?;
|
||||
if previous.is_some_and(|prior| {
|
||||
prior.file > at.file || (prior.file == at.file && prior.span.end > at.span.start)
|
||||
}) {
|
||||
return Err(error(at.span, "Overlapping or unordered source edits."));
|
||||
}
|
||||
previous = Some(at);
|
||||
}
|
||||
for (key, at) in self.index.occurrences() {
|
||||
let required = self
|
||||
.names
|
||||
.get(&key.target)
|
||||
.is_some_and(|name| self.index.names().get(&key.target) != Some(name));
|
||||
if required != seen.contains(key) {
|
||||
return Err(error(
|
||||
at.span,
|
||||
"Edit set differs from the complete qualified rename plan.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Standalone output preflight, after ownership validation. Composition
|
||||
/// defers this hypothetical typed-only size to its future combined emitter.
|
||||
fn output_lengths(
|
||||
&self,
|
||||
current: &BTreeMap<usize, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<BTreeMap<usize, usize>, Error> {
|
||||
let mut lengths: BTreeMap<_, _> = current
|
||||
.iter()
|
||||
.map(|(&file, source)| (file, source.len()))
|
||||
.collect();
|
||||
for edit in &self.edits {
|
||||
let at = edit.location;
|
||||
let length = lengths.get_mut(&at.file).expect("validated edit file");
|
||||
*length = length
|
||||
.checked_sub(at.span.end - at.span.start)
|
||||
.and_then(|size| size.checked_add(self.names[&edit.key.target].len()))
|
||||
.ok_or_else(|| error(at.span, "Updated source size overflow."))?;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for length in lengths.values() {
|
||||
total = total
|
||||
.checked_add(*length)
|
||||
.ok_or_else(|| error(Span::default(), "Updated bundle size overflow."))?;
|
||||
budget.index_bytes(total, Span::default())?;
|
||||
}
|
||||
Ok(lengths)
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
&self,
|
||||
current: &BTreeMap<usize, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<BTreeMap<usize, String>, Error> {
|
||||
self.validate(current, budget)?;
|
||||
let lengths = self.output_lengths(current, budget)?;
|
||||
let mut result = BTreeMap::new();
|
||||
let mut edits = self.edits.iter().peekable();
|
||||
for (&file, source) in current {
|
||||
let mut output = String::with_capacity(lengths[&file]);
|
||||
let mut start = 0;
|
||||
while edits.peek().is_some_and(|edit| edit.location.file == file) {
|
||||
let edit = edits.next().expect("peeked edit");
|
||||
output.push_str(&source[start..edit.location.span.start]);
|
||||
output.push_str(&self.names[&edit.key.target]);
|
||||
start = edit.location.span.end;
|
||||
}
|
||||
output.push_str(&source[start..]);
|
||||
result.insert(file, output);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Private typed-only composition planning. The caller must supply its inherited
|
||||
/// parse Budget; this type keeps that same immutable budget through export.
|
||||
/// It does not establish the future complete bundle/file-table/parse-session
|
||||
/// witness. In particular, an Index currently does not store its parse profile.
|
||||
pub(super) struct Composition<'b, 'i, 's> {
|
||||
plan: Plan<'i, 's>,
|
||||
budget: &'b Budget,
|
||||
}
|
||||
impl std::fmt::Debug for Composition<'_, '_, '_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Composition")
|
||||
.field("plan", &self.plan)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
impl<'b, 'i, 's> Composition<'b, 'i, 's> {
|
||||
pub fn renames(
|
||||
index: &'i Index<'s>,
|
||||
renames: &BTreeMap<Target, String>,
|
||||
inherited: &'b Budget,
|
||||
) -> Result<Self, Error> {
|
||||
let plan = Plan::prepare(index, renames, inherited)?;
|
||||
plan.validate(index.sources(), inherited)?;
|
||||
Ok(Self {
|
||||
plan,
|
||||
budget: inherited,
|
||||
})
|
||||
}
|
||||
|
||||
/// Export the entire validated plan against exact original numeric file keys
|
||||
/// and bytes. There is no output allocation, application or subset-accept API.
|
||||
/// A future collector must check this actual index witness and the complete
|
||||
/// combined output bounds before applying either producer's edits once.
|
||||
pub fn original_edits(
|
||||
&self,
|
||||
current: &BTreeMap<usize, String>,
|
||||
) -> Result<OriginalEdits<'_, 'i, 's>, Error> {
|
||||
Plan::validate_names(self.plan.index, &self.plan.names, self.budget)?;
|
||||
self.plan.validate(current, self.budget)?;
|
||||
Ok(OriginalEdits { plan: &self.plan })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct OriginalEdits<'p, 'i, 's> {
|
||||
plan: &'p Plan<'i, 's>,
|
||||
}
|
||||
impl<'s> OriginalEdits<'_, '_, 's> {
|
||||
pub fn index(&self) -> &Index<'s> {
|
||||
self.plan.index
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.plan.edits.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.plan.edits.is_empty()
|
||||
}
|
||||
pub fn iter(&self) -> impl ExactSizeIterator<Item = OriginalEdit<'_>> {
|
||||
self.plan.edits.iter().map(|edit| OriginalEdit {
|
||||
key: &edit.key,
|
||||
location: edit.location,
|
||||
original: &self.plan.index.sources()[&edit.location.file]
|
||||
[edit.location.span.start..edit.location.span.end],
|
||||
replacement: &self.plan.names[&edit.key.target],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A borrowed inspection record, never independent edit authority. Only an
|
||||
/// intact OriginalEdits batch can carry this producer's complete rename intent.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct OriginalEdit<'p> {
|
||||
key: &'p OccurrenceKey,
|
||||
location: Location,
|
||||
original: &'p str,
|
||||
replacement: &'p str,
|
||||
}
|
||||
impl OriginalEdit<'_> {
|
||||
pub fn key(&self) -> &OccurrenceKey {
|
||||
self.key
|
||||
}
|
||||
pub fn location(&self) -> Location {
|
||||
self.location
|
||||
}
|
||||
pub fn original(&self) -> &str {
|
||||
self.original
|
||||
}
|
||||
pub fn replacement(&self) -> &str {
|
||||
self.replacement
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,744 @@
|
||||
use super::super::budget::Limits;
|
||||
use super::*;
|
||||
use crate::analysis as a;
|
||||
use crate::analysis_numeric::Exact;
|
||||
use crate::language::ParseLimits;
|
||||
use crate::{Id, Layer, Project};
|
||||
|
||||
const DEFINITIONS: &str = "// 雪 @f(a:) $a: comment spellings stay exact\r\n\
|
||||
calculation f \"f a b\" id \"00000000-0000-0000-0000-000000000100\" {\r\n\
|
||||
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
|
||||
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
|
||||
result Number; body $a + $b;\r\n}\r\n\
|
||||
calculation g \"g a b\" id \"00000000-0000-0000-0000-000000000200\" {\r\n\
|
||||
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
|
||||
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
|
||||
result Number; body $a + $b;\r\n}\r\n";
|
||||
const CALLER: &str = "calculation caller \"Caller\" id \"00000000-0000-0000-0000-000000000300\" {\n\
|
||||
result Number; body @f(b: 2, a: 1) // first 雪 @f(a:)\n\
|
||||
+ @f(a: 3, b: 4) // second\n\
|
||||
+ @g(a: 5, b: 6);\n}\n";
|
||||
fn budget() -> Budget {
|
||||
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||
}
|
||||
fn global(id: u128) -> Target {
|
||||
Target::Global(Id::from_u128(id))
|
||||
}
|
||||
fn parameter(calculation: u128, parameter: u128) -> Target {
|
||||
Target::Parameter {
|
||||
calculation: Id::from_u128(calculation),
|
||||
parameter: a::ParameterId(Id::from_u128(parameter)),
|
||||
}
|
||||
}
|
||||
fn sources() -> BTreeMap<usize, String> {
|
||||
BTreeMap::from([(11, DEFINITIONS.into()), (23, CALLER.into())])
|
||||
}
|
||||
fn skeleton() -> Project {
|
||||
let mut project = super::super::model_oracle::project(Layer::Oa);
|
||||
project.definitions.clear();
|
||||
project
|
||||
.elements
|
||||
.retain(|_, element| !element.kind().is_analysis());
|
||||
project
|
||||
}
|
||||
fn binary(left: a::Expr, right: a::Expr) -> a::Expr {
|
||||
a::Expr::Binary {
|
||||
op: a::BinaryOp::Add,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
fn expected(renamed: bool) -> Project {
|
||||
// Independent model construction: no parsed AST, source index, writer or
|
||||
// generated edit participates in this expected complete Project.
|
||||
let mut project = skeleton();
|
||||
let p = |id| a::ParameterId(Id::from_u128(id));
|
||||
for (id, alias, name) in [
|
||||
(0x100, if renamed { "g" } else { "f" }, "f a b"),
|
||||
(0x200, if renamed { "f" } else { "g" }, "g a b"),
|
||||
] {
|
||||
let names = if renamed && id == 0x100 {
|
||||
["b", "a"]
|
||||
} else {
|
||||
["a", "b"]
|
||||
};
|
||||
let body = a::CalculationDefinition {
|
||||
parameters: [(0x200, names[0]), (0x201, names[1])]
|
||||
.into_iter()
|
||||
.map(|(id, name)| a::Parameter {
|
||||
id: p(id),
|
||||
name: name.into(),
|
||||
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
})
|
||||
.collect(),
|
||||
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
body: a::Binding::Expression(binary(
|
||||
a::Expr::Parameter(p(0x200)),
|
||||
a::Expr::Parameter(p(0x201)),
|
||||
)),
|
||||
};
|
||||
let id = Id::from_u128(id);
|
||||
project.definitions.insert(
|
||||
id,
|
||||
a::Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: a::DefinitionBody::Calculation(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
let call = |id, x, y| a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(Id::from_u128(id)),
|
||||
arguments: BTreeMap::from([
|
||||
(p(0x200), a::Expr::Number(Exact::from_integer(x))),
|
||||
(p(0x201), a::Expr::Number(Exact::from_integer(y))),
|
||||
]),
|
||||
};
|
||||
let id = Id::from_u128(0x300);
|
||||
project.definitions.insert(
|
||||
id,
|
||||
a::Definition {
|
||||
id,
|
||||
alias: "caller".into(),
|
||||
name: "Caller".into(),
|
||||
description: String::new(),
|
||||
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: Vec::new(),
|
||||
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
body: a::Binding::Expression(binary(
|
||||
binary(call(0x100, 1, 2), call(0x100, 3, 4)),
|
||||
call(0x200, 5, 6),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
);
|
||||
project
|
||||
}
|
||||
fn merge(mut project: Project, lowered: super::super::lower::Lowered) -> Project {
|
||||
project.definitions.extend(lowered.definitions);
|
||||
project.elements.extend(lowered.elements);
|
||||
project
|
||||
}
|
||||
fn rename_map() -> BTreeMap<Target, String> {
|
||||
BTreeMap::from([
|
||||
(global(0x100), "g".into()),
|
||||
(global(0x200), "f".into()),
|
||||
(parameter(0x100, 0x200), "b".into()),
|
||||
(parameter(0x100, 0x201), "a".into()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_global_and_scoped_swaps_match_original_token_and_whole_model_oracles() {
|
||||
let sources = sources();
|
||||
let mut work = budget();
|
||||
let (index, lowered) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
assert_eq!(merge(skeleton(), lowered), expected(false));
|
||||
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
|
||||
// List expected edits against the original authored bytes independently of
|
||||
// the index. Full contexts deliberately exclude equal strings/comments.
|
||||
let mut edits = Vec::new();
|
||||
let mut add = |file, context: &str, token: &str, replacement: &str| {
|
||||
let source = &sources[&file];
|
||||
let context_start = source.find(context).unwrap();
|
||||
let start = context_start + context.find(token).unwrap();
|
||||
edits.push((file, start, start + token.len(), replacement.to_owned()));
|
||||
};
|
||||
add(11, "calculation f ", "f", "g");
|
||||
add(11, "calculation g ", "g", "f");
|
||||
add(11, "input a id", "a", "b");
|
||||
add(11, "input b id", "b", "a");
|
||||
add(11, "body $a + $b;", "a", "b");
|
||||
add(11, "body $a + $b;", "b;", "a;");
|
||||
// The last oracle edit above includes ';' to disambiguate 'b' in 'body';
|
||||
// restrict it back to the exact name token for the span oracle.
|
||||
edits.last_mut().unwrap().2 -= 1;
|
||||
edits.last_mut().unwrap().3.pop();
|
||||
for call in ["@f(b: 2, a: 1)", "@f(a: 3, b: 4)"] {
|
||||
add_call_edits(&sources, &mut edits, call);
|
||||
}
|
||||
let start = CALLER.find("@g(a: 5, b: 6)").unwrap() + 1;
|
||||
edits.push((23, start, start + 1, "f".into()));
|
||||
edits.sort_by_key(|edit| (edit.0, edit.1));
|
||||
assert_eq!(plan.edit_count(), 13);
|
||||
assert_eq!(
|
||||
plan.edits
|
||||
.iter()
|
||||
.map(|edit| (
|
||||
edit.location.file,
|
||||
edit.location.span.start,
|
||||
edit.location.span.end,
|
||||
plan.names[&edit.key.target].clone()
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
edits
|
||||
);
|
||||
let mut exact = BTreeMap::new();
|
||||
for (&file, source) in &sources {
|
||||
let mut output = source.clone();
|
||||
for (_, start, end, value) in edits.iter().filter(|edit| edit.0 == file).rev() {
|
||||
output.replace_range(*start..*end, value);
|
||||
}
|
||||
exact.insert(file, output);
|
||||
}
|
||||
let output = plan.apply(&sources, &work).unwrap();
|
||||
assert_eq!(output, exact);
|
||||
assert!(output[&11].contains("// 雪 @f(a:) $a: comment spellings stay exact\r\n"));
|
||||
assert!(output[&23].contains("// first 雪 @f(a:)\n"));
|
||||
assert!(output[&11].contains("\"f a b\""));
|
||||
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
|
||||
let actual = merge(skeleton(), lowered);
|
||||
assert_eq!(actual, expected(true));
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
}
|
||||
fn add_call_edits(
|
||||
sources: &BTreeMap<usize, String>,
|
||||
edits: &mut Vec<(usize, usize, usize, String)>,
|
||||
call: &str,
|
||||
) {
|
||||
let start = sources[&23].find(call).unwrap();
|
||||
for (offset, replacement) in [
|
||||
(1, "g"),
|
||||
(call.find("a:").unwrap(), "b"),
|
||||
(call.find("b:").unwrap(), "a"),
|
||||
] {
|
||||
edits.push((23, start + offset, start + offset + 1, replacement.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_edits_preserve_unit_type_override_tokens_and_unrelated_authored_bytes() {
|
||||
let source = include_str!("../lower/fixtures/typed-oa.arcadia")
|
||||
.replace("750 [@millisecond]", "750 // exact 雪\r\n [@millisecond]");
|
||||
let files = BTreeMap::from([(59, source.clone())]);
|
||||
let mut work = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &files, &mut work).unwrap();
|
||||
let mut expected_project = super::super::model_oracle::project(Layer::Oa);
|
||||
let id = |name: &str| {
|
||||
index
|
||||
.names()
|
||||
.iter()
|
||||
.find(|(target, alias)| matches!(target, Target::Global(_)) && alias.as_str() == name)
|
||||
.map(|(target, _)| *target)
|
||||
.unwrap()
|
||||
};
|
||||
let renames = BTreeMap::from([
|
||||
(id("duration"), "time_kind".into()),
|
||||
(id("millisecond"), "ms_unit".into()),
|
||||
(id("receive_time"), "arrival".into()),
|
||||
]);
|
||||
let plan = Plan::renames(&index, &renames, &work).unwrap();
|
||||
let output = plan.apply(&files, &work).unwrap();
|
||||
let expected_source = source
|
||||
.replace("quantity_kind duration ", "quantity_kind time_kind ")
|
||||
.replace("@duration", "@time_kind")
|
||||
.replace("unit millisecond ", "unit ms_unit ")
|
||||
.replace("@millisecond", "@ms_unit")
|
||||
.replace("oa_property receive_time ", "oa_property arrival ")
|
||||
.replace("@receive_time", "@arrival");
|
||||
assert_eq!(output[&59], expected_source);
|
||||
for definition in expected_project.definitions.values_mut() {
|
||||
if let Some(name) = renames.get(&Target::Global(definition.id)) {
|
||||
definition.alias = name.clone();
|
||||
}
|
||||
}
|
||||
for element in expected_project.elements.values_mut() {
|
||||
if let Some(name) = renames.get(&Target::Global(element.id)) {
|
||||
element.alias = name.clone();
|
||||
}
|
||||
}
|
||||
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
|
||||
assert_eq!(merge(skeleton(), lowered), expected_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_rejects_unknown_invalid_and_colliding_final_names_but_allows_noop() {
|
||||
let sources = sources();
|
||||
let work = &mut budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, work).unwrap();
|
||||
for renames in [
|
||||
BTreeMap::from([(global(999), "new".into())]),
|
||||
BTreeMap::from([(global(0x100), "bad name".into())]),
|
||||
BTreeMap::from([(global(0x100), "g".into())]),
|
||||
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
|
||||
] {
|
||||
assert!(Plan::renames(&index, &renames, work).is_err());
|
||||
}
|
||||
let plan = Plan::renames(&index, &BTreeMap::from([(global(0x100), "f".into())]), work).unwrap();
|
||||
assert_eq!(plan.edit_count(), 0);
|
||||
assert_eq!(plan.apply(&sources, work).unwrap(), sources);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_file_sets_bytes_forged_spans_and_duplicate_edits_fail_before_output() {
|
||||
let sources = sources();
|
||||
let mut work = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
for damage in 0..6 {
|
||||
let mut current = sources.clone();
|
||||
let mut plan = Plan::renames(&index, &rename_map(), &work).unwrap();
|
||||
match damage {
|
||||
0 => current
|
||||
.get_mut(&23)
|
||||
.unwrap()
|
||||
.push_str("// unrelated change"),
|
||||
1 => {
|
||||
current.remove(&23);
|
||||
}
|
||||
2 => {
|
||||
current.insert(99, String::new());
|
||||
}
|
||||
3 => plan.edits[0].location.span.start += 1,
|
||||
4 => plan.edits.insert(1, plan.edits[0].clone()),
|
||||
_ => {
|
||||
plan.edits.pop();
|
||||
}
|
||||
}
|
||||
assert!(plan.apply(¤t, &work).is_err(), "damage {damage}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_and_plan_limits_are_aggregate_and_do_not_reset_parse_work() {
|
||||
let sources = sources();
|
||||
let mut work = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
let usage = work.usage;
|
||||
let numeric = work.numeric_units();
|
||||
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
|
||||
let output = plan.apply(&sources, &work).unwrap();
|
||||
assert_eq!(work.usage, usage);
|
||||
assert_eq!(work.numeric_units(), numeric);
|
||||
let bytes = output.values().map(String::len).sum();
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.bytes = bytes;
|
||||
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_ok());
|
||||
limits.bytes -= 1;
|
||||
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_err());
|
||||
limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.references = 0;
|
||||
limits.entries = 0;
|
||||
assert!(Plan::renames(&index, &rename_map(), &Budget::new(limits).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_exports_complete_borrowed_swap_witnesses_and_empty_noops() {
|
||||
use super::super::index::TokenRole;
|
||||
use super::super::lower::{Child, Field, Role};
|
||||
type Key = (Id, Option<Field>, Option<Vec<Child>>, TokenRole, Target);
|
||||
type Row = (Location, String, String, Key);
|
||||
|
||||
let sources = sources();
|
||||
let mut work = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
let usage = work.usage;
|
||||
let numeric = work.numeric_units();
|
||||
let composition = Composition::renames(&index, &rename_map(), &work).unwrap();
|
||||
assert!(std::ptr::eq(composition.budget, &work));
|
||||
// A byte-identical snapshot is valid; records still borrow the actual index.
|
||||
let batch = composition.original_edits(&sources.clone()).unwrap();
|
||||
assert!(std::ptr::eq(batch.index(), &index));
|
||||
assert_eq!(batch.len(), 13);
|
||||
assert!(!batch.is_empty());
|
||||
assert_eq!(batch.iter().len(), 13);
|
||||
|
||||
// Independently authored occurrence identities and full source contexts.
|
||||
// No index offset/path or generated output defines this oracle.
|
||||
let mut expected: Vec<Row> = Vec::new();
|
||||
let mut add = |file, context: &str, prefix: &str, old: &str, new: &str, key: Key| {
|
||||
let start = sources[&file].find(context).unwrap() + prefix.len();
|
||||
assert_eq!(&sources[&file][start..start + old.len()], old);
|
||||
expected.push((
|
||||
Location {
|
||||
file,
|
||||
span: Span {
|
||||
start,
|
||||
end: start + old.len(),
|
||||
},
|
||||
},
|
||||
old.into(),
|
||||
new.into(),
|
||||
key,
|
||||
));
|
||||
};
|
||||
for (id, old, new, context) in [
|
||||
(0x100, "f", "g", "calculation f "),
|
||||
(0x200, "g", "f", "calculation g "),
|
||||
] {
|
||||
add(
|
||||
11,
|
||||
context,
|
||||
"calculation ",
|
||||
old,
|
||||
new,
|
||||
(
|
||||
Id::from_u128(id),
|
||||
None,
|
||||
None,
|
||||
TokenRole::Declaration,
|
||||
global(id),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (id, old, new, context, edge, prefix) in [
|
||||
(0x200, "a", "b", "input a id", Child::Left, "body $"),
|
||||
(0x201, "b", "a", "input b id", Child::Right, "body $a + $"),
|
||||
] {
|
||||
let target = parameter(0x100, id);
|
||||
add(
|
||||
11,
|
||||
context,
|
||||
"input ",
|
||||
old,
|
||||
new,
|
||||
(
|
||||
Id::from_u128(0x100),
|
||||
Some(Field::Input(a::ParameterId(Id::from_u128(id)))),
|
||||
None,
|
||||
TokenRole::Use(Role::ParameterDeclaration),
|
||||
target,
|
||||
),
|
||||
);
|
||||
add(
|
||||
11,
|
||||
"body $a + $b;",
|
||||
prefix,
|
||||
old,
|
||||
new,
|
||||
(
|
||||
Id::from_u128(0x100),
|
||||
Some(Field::Body),
|
||||
Some(vec![edge]),
|
||||
TokenRole::Use(Role::ParameterUse),
|
||||
target,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (context, path) in [
|
||||
("@f(b: 2, a: 1)", vec![Child::Left, Child::Left]),
|
||||
("@f(a: 3, b: 4)", vec![Child::Left, Child::Right]),
|
||||
] {
|
||||
add(
|
||||
23,
|
||||
context,
|
||||
"@",
|
||||
"f",
|
||||
"g",
|
||||
(
|
||||
Id::from_u128(0x300),
|
||||
Some(Field::Body),
|
||||
Some(path.clone()),
|
||||
TokenRole::Use(Role::CallTarget),
|
||||
global(0x100),
|
||||
),
|
||||
);
|
||||
for (id, old, new) in [(0x200, "a", "b"), (0x201, "b", "a")] {
|
||||
let offset = context.find(&format!("{old}:")).unwrap();
|
||||
add(
|
||||
23,
|
||||
context,
|
||||
&context[..offset],
|
||||
old,
|
||||
new,
|
||||
(
|
||||
Id::from_u128(0x300),
|
||||
Some(Field::Body),
|
||||
Some(path.clone()),
|
||||
TokenRole::Use(Role::ArgumentLabel),
|
||||
parameter(0x100, id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
add(
|
||||
23,
|
||||
"@g(a: 5, b: 6)",
|
||||
"@",
|
||||
"g",
|
||||
"f",
|
||||
(
|
||||
Id::from_u128(0x300),
|
||||
Some(Field::Body),
|
||||
Some(vec![Child::Right]),
|
||||
TokenRole::Use(Role::CallTarget),
|
||||
global(0x200),
|
||||
),
|
||||
);
|
||||
expected.sort_by_key(|row| (row.0.file, row.0.span.start, row.0.span.end));
|
||||
let actual: Vec<Row> = batch
|
||||
.iter()
|
||||
.map(|edit| {
|
||||
let key = edit.key();
|
||||
(
|
||||
edit.location(),
|
||||
edit.original().into(),
|
||||
edit.replacement().into(),
|
||||
(
|
||||
key.owner,
|
||||
key.field,
|
||||
key.path.as_ref().map(|path| path.edges()),
|
||||
key.role,
|
||||
key.target,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
let noop = Composition::renames(
|
||||
&index,
|
||||
&BTreeMap::from([
|
||||
(global(0x100), "f".into()),
|
||||
(parameter(0x200, 0x200), "a".into()),
|
||||
]),
|
||||
&work,
|
||||
)
|
||||
.unwrap();
|
||||
let empty = noop.original_edits(&sources).unwrap();
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.len(), 0);
|
||||
assert_eq!(empty.iter().count(), 0);
|
||||
assert_eq!(work.usage, usage);
|
||||
assert_eq!(work.numeric_units(), numeric);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_rejects_stale_or_damaged_complete_batches_before_export() {
|
||||
let mut sources = sources();
|
||||
sources.insert(99, "// unrelated 雪\r\n".into());
|
||||
let original = sources.clone();
|
||||
let mut work = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
for damage in 0..10 {
|
||||
let mut current = sources.clone();
|
||||
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
|
||||
match damage {
|
||||
0 => current.get_mut(&99).unwrap().push_str("changed"),
|
||||
1 => {
|
||||
current.remove(&23);
|
||||
}
|
||||
2 => {
|
||||
current.insert(101, String::new());
|
||||
}
|
||||
3 => composition.plan.edits[0].location.span.start += 1,
|
||||
4 => composition
|
||||
.plan
|
||||
.edits
|
||||
.insert(1, composition.plan.edits[0].clone()),
|
||||
5 => {
|
||||
composition.plan.edits.pop();
|
||||
}
|
||||
6 => composition.plan.edits.swap(0, 1),
|
||||
7 => {
|
||||
composition
|
||||
.plan
|
||||
.names
|
||||
.insert(global(0x100), "bad name".into());
|
||||
}
|
||||
8 => {
|
||||
composition.plan.names.insert(global(0x100), "f".into());
|
||||
}
|
||||
_ => {
|
||||
composition.plan.names.insert(global(999), "missing".into());
|
||||
}
|
||||
}
|
||||
let before = current.clone();
|
||||
assert!(
|
||||
composition.original_edits(¤t).is_err(),
|
||||
"damage {damage}"
|
||||
);
|
||||
assert_eq!(current, before);
|
||||
}
|
||||
assert_eq!(sources, original);
|
||||
let failure = work.frame(257, Span::default()).unwrap_err();
|
||||
assert_eq!(
|
||||
Composition::renames(&index, &rename_map(), &work).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_defers_only_typed_output_size_with_independent_shrink_arithmetic() {
|
||||
let sources = sources();
|
||||
let cap = sources.values().map(String::len).sum();
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.bytes = cap;
|
||||
let mut work = Budget::new(limits).unwrap();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
assert_eq!(work.usage.bytes, cap);
|
||||
let usage = work.usage;
|
||||
let numeric = work.numeric_units();
|
||||
let names = BTreeMap::from([(global(0x100), "ff".into())]);
|
||||
assert_eq!(
|
||||
Plan::renames(&index, &names, &work).unwrap_err().message,
|
||||
"Source8 index/output byte limit exceeded."
|
||||
);
|
||||
let composition = Composition::renames(&index, &names, &work).unwrap();
|
||||
let batch = composition.original_edits(&sources).unwrap();
|
||||
assert_eq!(batch.len(), 3); // declaration and two distinct call targets
|
||||
let growth: usize = batch
|
||||
.iter()
|
||||
.map(|edit| edit.replacement().len() - edit.original().len())
|
||||
.sum();
|
||||
assert_eq!(growth, 3);
|
||||
assert_eq!(
|
||||
composition.plan.apply(&sources, &work).unwrap_err().message,
|
||||
"Source8 index/output byte limit exceeded."
|
||||
);
|
||||
|
||||
// Independent future ordinary/scalar producer witness on untouched display
|
||||
// name bytes. This is size/offset arithmetic only: this typed-only harness
|
||||
// has no ordinary collector, mixed ownership proof or accepted output.
|
||||
let start = DEFINITIONS.find("\"f a b\"").unwrap() + 1;
|
||||
let shrink = Location {
|
||||
file: 11,
|
||||
span: Span {
|
||||
start,
|
||||
end: start + "f a b".len(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
&sources[&shrink.file][shrink.span.start..shrink.span.end],
|
||||
"f a b"
|
||||
);
|
||||
for edit in batch.iter() {
|
||||
let at = edit.location();
|
||||
assert!(
|
||||
at.file != shrink.file
|
||||
|| at.span.end <= shrink.span.start
|
||||
|| shrink.span.end <= at.span.start
|
||||
);
|
||||
}
|
||||
let original_len = shrink.span.end - shrink.span.start;
|
||||
assert_eq!(cap + growth - original_len + "fa".len(), cap);
|
||||
assert_eq!(cap + growth - original_len + "fab".len(), cap + 1);
|
||||
assert_eq!(work.usage, usage);
|
||||
assert_eq!(work.numeric_units(), numeric);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_keeps_name_storage_and_rename_record_limits_before_allocation() {
|
||||
let sources = sources();
|
||||
let cap = sources.values().map(String::len).sum();
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.bytes = cap;
|
||||
let mut work = Budget::new(limits).unwrap();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
let exact = BTreeMap::from([(global(0x100), "x".repeat(cap))]);
|
||||
assert_eq!(
|
||||
Composition::renames(&index, &exact, &work)
|
||||
.unwrap()
|
||||
.original_edits(&sources)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
let over = BTreeMap::from([(global(0x100), "x".repeat(cap + 1))]);
|
||||
assert_eq!(
|
||||
Composition::renames(&index, &over, &work)
|
||||
.unwrap_err()
|
||||
.message,
|
||||
"Source8 index/output byte limit exceeded."
|
||||
);
|
||||
for invalid in [
|
||||
BTreeMap::from([(global(999), "unknown".into())]),
|
||||
BTreeMap::from([(global(0x100), "bad name".into())]),
|
||||
BTreeMap::from([(global(0x100), "g".into())]),
|
||||
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
|
||||
] {
|
||||
assert!(Composition::renames(&index, &invalid, &work).is_err());
|
||||
}
|
||||
|
||||
// A typed-empty fixture isolates the rename-map record bound without
|
||||
// syntax entry usage masking it. No ordinary edits are authorized here.
|
||||
let ordinary = skeleton();
|
||||
let count = ordinary.identities().len();
|
||||
let files = BTreeMap::from([(1, "// empty typed unit\n".into())]);
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = count;
|
||||
limits.references = 0;
|
||||
let mut inherited = Budget::new(limits).unwrap();
|
||||
let (index, _) = Index::parse(&ordinary, &files, &mut inherited).unwrap();
|
||||
let mut names: BTreeMap<_, _> = index
|
||||
.names()
|
||||
.keys()
|
||||
.enumerate()
|
||||
.map(|(i, target)| (*target, format!("renamed_{i}")))
|
||||
.collect();
|
||||
assert_eq!(names.len(), count);
|
||||
assert!(
|
||||
Composition::renames(&index, &names, &inherited)
|
||||
.unwrap()
|
||||
.original_edits(&files)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
names.insert(global(999), "additional".into());
|
||||
assert_eq!(
|
||||
Composition::renames(&index, &names, &inherited)
|
||||
.unwrap_err()
|
||||
.message,
|
||||
"Source8 lowering entry limit exceeded."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_checks_edit_record_ceiling_before_duplicate_membership() {
|
||||
let sources = sources();
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = 128;
|
||||
limits.references = 32;
|
||||
let ceiling = limits.entries + limits.references;
|
||||
let mut work = Budget::new(limits).unwrap();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
|
||||
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
|
||||
assert_eq!(composition.original_edits(&sources).unwrap().len(), 13);
|
||||
let duplicate = composition.plan.edits[0].clone();
|
||||
composition.plan.edits = vec![duplicate.clone(); ceiling];
|
||||
// At the exact count bound validation proceeds to the independent ownership
|
||||
// error. The damaged plan never becomes a successful/authoritative batch.
|
||||
assert_eq!(
|
||||
composition.original_edits(&sources).unwrap_err().message,
|
||||
"Forged or duplicate edit occurrence."
|
||||
);
|
||||
composition.plan.edits.push(duplicate);
|
||||
assert_eq!(
|
||||
composition.original_edits(&sources).unwrap_err().message,
|
||||
"Source8 lowering occurrence limit exceeded."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_noop_apply_keeps_tighter_file_only_budget_and_output_boundary() {
|
||||
let sources = sources();
|
||||
let mut inherited = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut inherited).unwrap();
|
||||
let plan = Plan::renames(
|
||||
&index,
|
||||
&BTreeMap::from([(global(0x100), "f".into())]),
|
||||
&inherited,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.edit_count(), 0);
|
||||
assert!(index.names().len() > sources.len());
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = sources.len();
|
||||
limits.references = 0;
|
||||
limits.bytes = sources.values().map(String::len).sum();
|
||||
// Standalone apply checks file/edit records, not the already validated
|
||||
// catalog/name-storage allocation from construction. Rechecking the catalog
|
||||
// under this intentionally tighter probe budget would reject this no-op.
|
||||
assert_eq!(
|
||||
plan.apply(&sources, &Budget::new(limits).unwrap()).unwrap(),
|
||||
sources
|
||||
);
|
||||
limits.bytes -= 1;
|
||||
assert_eq!(
|
||||
plan.apply(&sources, &Budget::new(limits).unwrap())
|
||||
.unwrap_err()
|
||||
.message,
|
||||
"Source8 index/output byte limit exceeded."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Private typed canonical output. Complete semantic admission belongs to the
|
||||
//! caller. No public formatter, version dispatch or source edits use this yet.
|
||||
mod expression;
|
||||
pub(super) use expression::{Parent, Side};
|
||||
|
||||
use super::{budget, lower::Field};
|
||||
use crate::analysis as a;
|
||||
use crate::analysis_numeric::Exact;
|
||||
use crate::{Id, Kind, Layer, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Limits {
|
||||
pub bytes: usize,
|
||||
pub entries: usize,
|
||||
}
|
||||
impl Limits {
|
||||
pub fn source8(caller: crate::language::ParseLimits) -> Self {
|
||||
let limits = budget::Limits::source8(caller);
|
||||
Self {
|
||||
bytes: limits.bytes,
|
||||
entries: limits.entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct Error {
|
||||
pub owner: Option<Id>,
|
||||
pub field: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) struct Usage {
|
||||
pub bytes: usize,
|
||||
pub entries: usize,
|
||||
}
|
||||
|
||||
struct Sink {
|
||||
buffer: Vec<u8>,
|
||||
written: usize,
|
||||
maximum: usize,
|
||||
}
|
||||
impl Sink {
|
||||
fn room(&self, bytes: usize) -> std::io::Result<()> {
|
||||
if bytes > self.maximum.saturating_sub(self.written) {
|
||||
return Err(std::io::Error::other(
|
||||
"Source8 writer output byte limit exceeded.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Write for Sink {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.room(bytes.len())?;
|
||||
self.buffer.extend_from_slice(bytes);
|
||||
self.written += bytes.len();
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Writer<'p> {
|
||||
project: &'p Project,
|
||||
parameters: BTreeMap<Id, BTreeMap<a::ParameterId, &'p a::Parameter>>,
|
||||
limits: Limits,
|
||||
entries: usize,
|
||||
sink: Sink,
|
||||
owner: Option<Id>,
|
||||
field: &'static str,
|
||||
failed: Option<Error>,
|
||||
}
|
||||
|
||||
impl<'p> Writer<'p> {
|
||||
pub fn new(project: &'p Project, limits: Limits) -> Result<Self, Error> {
|
||||
let hard = Limits::source8(crate::language::ParseLimits::UNLIMITED);
|
||||
if limits.bytes > hard.bytes || limits.entries > hard.entries {
|
||||
return Err(Error {
|
||||
owner: None,
|
||||
field: "writer",
|
||||
message: "Writer limits exceed the source8 profile.".into(),
|
||||
});
|
||||
}
|
||||
let mut writer = Self {
|
||||
project,
|
||||
parameters: BTreeMap::new(),
|
||||
limits,
|
||||
entries: 0,
|
||||
sink: Sink {
|
||||
buffer: Vec::new(),
|
||||
written: 0,
|
||||
maximum: limits.bytes,
|
||||
},
|
||||
owner: None,
|
||||
field: "writer",
|
||||
failed: None,
|
||||
};
|
||||
for definition in project.definitions.values() {
|
||||
writer.owner = Some(definition.id);
|
||||
writer.field = "parameters";
|
||||
writer.entry()?;
|
||||
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut names = BTreeSet::new();
|
||||
for parameter in &calculation.parameters {
|
||||
writer.entry()?;
|
||||
if parameters.contains_key(¶meter.id)
|
||||
|| !names.insert(parameter.name.as_str())
|
||||
{
|
||||
return Err(writer.error("Duplicate scoped parameter identity or name."));
|
||||
}
|
||||
parameters.insert(parameter.id, parameter);
|
||||
}
|
||||
writer.parameters.insert(definition.id, parameters);
|
||||
}
|
||||
}
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> Usage {
|
||||
Usage {
|
||||
bytes: self.sink.written,
|
||||
entries: self.entries,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(&self, message: impl Into<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(¶meter.name)?;
|
||||
self.text(" id \"")?;
|
||||
self.text(¶meter.id.0.to_string())?;
|
||||
self.text("\" : ")?;
|
||||
self.type_value(¶meter.value_type)?;
|
||||
self.text(";")
|
||||
}
|
||||
pub fn parameter(
|
||||
&mut self,
|
||||
calculation: Id,
|
||||
parameter: a::ParameterId,
|
||||
) -> Result<String, Error> {
|
||||
self.run(calculation, "input", |writer| {
|
||||
writer.parameter_value(calculation, parameter)
|
||||
})
|
||||
}
|
||||
fn override_value(&mut self, property: Id, binding: &a::Binding) -> Result<(), Error> {
|
||||
self.entry()?;
|
||||
let alias = self.property_name(property)?;
|
||||
self.text("@")?;
|
||||
self.name(alias)?;
|
||||
self.text(" = ")?;
|
||||
self.binding_value(binding, None)?;
|
||||
self.text(";")
|
||||
}
|
||||
pub fn override_entry(&mut self, case: Id, property: Id) -> Result<String, Error> {
|
||||
self.run(case, "overrides", |writer| {
|
||||
let (binding, _) = writer.model_binding(case, Field::Override(property))?;
|
||||
writer.override_value(property, binding)
|
||||
})
|
||||
}
|
||||
fn header(
|
||||
&mut self,
|
||||
word: &str,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
id: Id,
|
||||
description: &str,
|
||||
) -> Result<(), Error> {
|
||||
self.entry()?;
|
||||
self.text(" ")?;
|
||||
self.text(word)?;
|
||||
self.text(" ")?;
|
||||
self.name(alias)?;
|
||||
self.text(" ")?;
|
||||
self.quote(name)?;
|
||||
self.text(" id \"")?;
|
||||
self.text(&id.to_string())?;
|
||||
self.text("\" {\n")?;
|
||||
self.start_field("description")?;
|
||||
self.quote(description)?;
|
||||
self.text(";\n")
|
||||
}
|
||||
fn start_field(&mut self, name: &'static str) -> Result<(), Error> {
|
||||
self.field = name;
|
||||
self.entry()?;
|
||||
self.text(" ")?;
|
||||
self.text(name)?;
|
||||
self.text(" ")
|
||||
}
|
||||
pub fn definition(&mut self, id: Id) -> Result<String, Error> {
|
||||
self.run(id, "definition", |writer| {
|
||||
let project = writer.project;
|
||||
let definition = project
|
||||
.definitions
|
||||
.get(&id)
|
||||
.ok_or_else(|| writer.error("Unknown definition."))?;
|
||||
let word = match &definition.body {
|
||||
a::DefinitionBody::QuantityKind(_) => "quantity_kind",
|
||||
a::DefinitionBody::Unit(_) => "unit",
|
||||
a::DefinitionBody::Calculation(_) => "calculation",
|
||||
};
|
||||
writer.header(
|
||||
word,
|
||||
&definition.alias,
|
||||
&definition.name,
|
||||
id,
|
||||
&definition.description,
|
||||
)?;
|
||||
match &definition.body {
|
||||
a::DefinitionBody::QuantityKind(kind) => {
|
||||
writer.start_field("dimension")?;
|
||||
writer.text("{")?;
|
||||
const AXES: [&str; 7] = [
|
||||
"length",
|
||||
"mass",
|
||||
"time",
|
||||
"electric_current",
|
||||
"temperature",
|
||||
"amount",
|
||||
"luminous_intensity",
|
||||
];
|
||||
let mut nonzero = false;
|
||||
for (axis, exponent) in AXES.into_iter().zip(kind.dimension.exponents()) {
|
||||
if exponent != 0 {
|
||||
writer.entry()?;
|
||||
writer.text(" ")?;
|
||||
writer.text(axis)?;
|
||||
writer.text(" ")?;
|
||||
writer.text(&exponent.to_string())?;
|
||||
writer.text(";")?;
|
||||
nonzero = true;
|
||||
}
|
||||
}
|
||||
if nonzero {
|
||||
writer.text(" ")?;
|
||||
}
|
||||
writer.text("};\n")?;
|
||||
writer.start_field("algebra")?;
|
||||
match kind.algebra {
|
||||
a::QuantityAlgebra::Linear => writer.text("Linear")?,
|
||||
a::QuantityAlgebra::Affine { difference } => {
|
||||
writer.text("Affine(")?;
|
||||
writer.definition_ref(difference, DefinitionKind::QuantityKind)?;
|
||||
writer.text(")")?;
|
||||
}
|
||||
}
|
||||
writer.text(";\n")?;
|
||||
}
|
||||
a::DefinitionBody::Unit(unit) => {
|
||||
writer.start_field("kind")?;
|
||||
writer.definition_ref(unit.kind, DefinitionKind::QuantityKind)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("scale")?;
|
||||
writer.exact(&unit.scale)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("offset")?;
|
||||
writer.exact(&unit.offset)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("symbol")?;
|
||||
writer.quote(&unit.symbol)?;
|
||||
writer.text(";\n")?;
|
||||
}
|
||||
a::DefinitionBody::Calculation(calculation) => {
|
||||
for parameter in &calculation.parameters {
|
||||
writer.field = "input";
|
||||
writer.text(" ")?;
|
||||
writer.parameter_value(id, parameter.id)?;
|
||||
writer.text("\n")?;
|
||||
}
|
||||
writer.start_field("result")?;
|
||||
writer.type_value(&calculation.result)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("body")?;
|
||||
writer.binding_value(&calculation.body, Some(id))?;
|
||||
writer.text(";\n")?;
|
||||
}
|
||||
}
|
||||
writer.text(" }\n\n")
|
||||
})
|
||||
}
|
||||
pub fn analysis_element(&mut self, id: Id) -> Result<String, Error> {
|
||||
self.run(id, "element", |writer| {
|
||||
let project = writer.project;
|
||||
let element = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.ok_or_else(|| writer.error("Unknown analysis element."))?;
|
||||
let word = match (element.kind(), element.layer()) {
|
||||
(Kind::EngineeringProperty, Layer::Oa) => "oa_property",
|
||||
(Kind::EngineeringProperty, Layer::Sa) => "sa_property",
|
||||
(Kind::AnalysisCheck, Layer::Oa) => "oa_check",
|
||||
(Kind::AnalysisCheck, Layer::Sa) => "sa_check",
|
||||
(Kind::AnalysisCase, Layer::Oa) => "oa_analysis",
|
||||
(Kind::AnalysisCase, Layer::Sa) => "sa_analysis",
|
||||
_ => return Err(writer.error("Expected an analysis element.")),
|
||||
};
|
||||
if element.parent.is_some() {
|
||||
return Err(writer.error("Analysis declarations must be roots."));
|
||||
}
|
||||
writer.header(
|
||||
word,
|
||||
&element.alias,
|
||||
&element.name,
|
||||
id,
|
||||
&element.description,
|
||||
)?;
|
||||
writer.start_field("parent")?;
|
||||
writer.text("none;\n")?;
|
||||
match element.shared().expect("analysis kind is a shared body") {
|
||||
SharedBody::EngineeringProperty {
|
||||
subject,
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
} => {
|
||||
writer.start_field("subject")?;
|
||||
writer.subject(*subject)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("type")?;
|
||||
writer.type_value(value_type)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("display_unit")?;
|
||||
if let Some(unit) = display_unit {
|
||||
writer.definition_ref(*unit, DefinitionKind::Unit)?;
|
||||
} else {
|
||||
writer.text("none")?;
|
||||
}
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("base")?;
|
||||
writer.binding_value(base, None)?;
|
||||
writer.text(";\n")?;
|
||||
}
|
||||
SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
} => {
|
||||
writer.start_field("subject")?;
|
||||
writer.optional_subject(*subject)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("documented_constraint")?;
|
||||
if let Some(id) = documented_constraint {
|
||||
let alias = project
|
||||
.elements
|
||||
.get(id)
|
||||
.filter(|element| element.kind() == Kind::Constraint)
|
||||
.map(|element| element.alias.as_str())
|
||||
.ok_or_else(|| {
|
||||
writer.error("Unknown constraint or wrong reference domain.")
|
||||
})?;
|
||||
writer.text("@")?;
|
||||
writer.name(alias)?;
|
||||
} else {
|
||||
writer.text("none")?;
|
||||
}
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("predicate")?;
|
||||
writer.binding_value(predicate, None)?;
|
||||
writer.text(";\n")?;
|
||||
}
|
||||
SharedBody::AnalysisCase {
|
||||
subject,
|
||||
base: a::BaselineBase::ModelBaseline,
|
||||
overrides,
|
||||
} => {
|
||||
writer.start_field("subject")?;
|
||||
writer.optional_subject(*subject)?;
|
||||
writer.text(";\n")?;
|
||||
writer.start_field("base")?;
|
||||
writer.text("ModelBaseline;\n")?;
|
||||
writer.start_field("overrides")?;
|
||||
if overrides.is_empty() {
|
||||
writer.text("{};\n")?;
|
||||
} else {
|
||||
writer.text("{\n")?;
|
||||
for (property, binding) in overrides {
|
||||
writer.text(" ")?;
|
||||
writer.override_value(*property, binding)?;
|
||||
writer.text("\n")?;
|
||||
}
|
||||
writer.text(" };\n")?;
|
||||
}
|
||||
}
|
||||
_ => return Err(writer.error("Expected an analysis body.")),
|
||||
}
|
||||
writer.text(" }\n\n")
|
||||
})
|
||||
}
|
||||
pub fn typed_declarations(&mut self) -> Result<Vec<(Id, String)>, Error> {
|
||||
if let Some(error) = &self.failed {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let project = self.project;
|
||||
let mut declarations = Vec::new();
|
||||
for id in project.definitions.keys() {
|
||||
declarations.push((*id, self.definition(*id)?));
|
||||
}
|
||||
for element in project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| element.kind().is_analysis())
|
||||
{
|
||||
declarations.push((element.id, self.analysis_element(element.id)?));
|
||||
}
|
||||
Ok(declarations)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DefinitionKind {
|
||||
QuantityKind,
|
||||
Unit,
|
||||
Calculation,
|
||||
}
|
||||
|
||||
fn binding_field(field: Field) -> &'static str {
|
||||
match field {
|
||||
Field::Body => "body",
|
||||
Field::Base => "base",
|
||||
Field::Predicate => "predicate",
|
||||
Field::Override(_) => "overrides",
|
||||
_ => "binding",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,284 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Parent {
|
||||
Root,
|
||||
Binary(a::BinaryOp, Side),
|
||||
Negate,
|
||||
}
|
||||
|
||||
fn operator(op: a::BinaryOp) -> (&'static str, u8) {
|
||||
use a::BinaryOp::*;
|
||||
match op {
|
||||
Or => (" or ", 1),
|
||||
And => (" and ", 2),
|
||||
Equal => (" == ", 3),
|
||||
NotEqual => (" != ", 3),
|
||||
Less => (" < ", 4),
|
||||
LessOrEqual => (" <= ", 4),
|
||||
Greater => (" > ", 4),
|
||||
GreaterOrEqual => (" >= ", 4),
|
||||
Add => (" + ", 5),
|
||||
Subtract => (" - ", 5),
|
||||
Multiply => (" * ", 6),
|
||||
Divide => (" / ", 6),
|
||||
}
|
||||
}
|
||||
fn comparison(op: a::BinaryOp) -> bool {
|
||||
matches!(operator(op).1, 3 | 4)
|
||||
}
|
||||
fn group(expression: &a::Expr, parent: Parent) -> bool {
|
||||
let a::Expr::Binary { op, .. } = expression else {
|
||||
return false;
|
||||
};
|
||||
match parent {
|
||||
Parent::Root => false,
|
||||
Parent::Negate => true,
|
||||
Parent::Binary(parent, side) => {
|
||||
(comparison(*op) && comparison(parent))
|
||||
|| operator(*op).1 < operator(parent).1
|
||||
|| (operator(*op).1 == operator(parent).1 && matches!(side, Side::Right))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Task<'a> {
|
||||
Expression(&'a a::Expr, Parent, usize),
|
||||
Text(&'static str),
|
||||
Alias(&'a str),
|
||||
Literal(&'a Exact),
|
||||
Exponent(i8),
|
||||
}
|
||||
fn schedule<'a>(
|
||||
writer: &Writer<'_>,
|
||||
tasks: &mut Vec<Task<'a>>,
|
||||
ordered: &[Task<'a>],
|
||||
) -> Result<(), Error> {
|
||||
writer.pending(tasks.len().saturating_add(ordered.len()))?;
|
||||
tasks.extend(ordered.iter().rev().copied());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn emit_expression(
|
||||
&mut self,
|
||||
expression: &a::Expr,
|
||||
calculation: Option<Id>,
|
||||
parent: Parent,
|
||||
) -> Result<(), Error> {
|
||||
self.pending(1)?;
|
||||
let mut tasks = vec![Task::Expression(expression, parent, 1)];
|
||||
while let Some(task) = tasks.pop() {
|
||||
match task {
|
||||
Task::Text(text) => self.text(text)?,
|
||||
Task::Alias(name) => {
|
||||
self.entry()?;
|
||||
self.name(name)?;
|
||||
}
|
||||
Task::Literal(exact) => self.exact(exact)?,
|
||||
Task::Exponent(exponent) => self.text(&exponent.to_string())?,
|
||||
Task::Expression(expression, parent, depth) => {
|
||||
self.entry()?;
|
||||
if depth > a::MAX_EXPRESSION_DEPTH {
|
||||
return Err(self.error("Expression depth exceeds128."));
|
||||
}
|
||||
if group(expression, parent) {
|
||||
self.text("(")?;
|
||||
schedule(self, &mut tasks, &[Task::Text(")")])?;
|
||||
}
|
||||
use Task::{Alias, Exponent, Expression as Child, Literal, Text};
|
||||
use a::Expr as E;
|
||||
match expression {
|
||||
E::Boolean(value) => self.text(if *value { "true" } else { "false" })?,
|
||||
E::Number(value) => self.exact(value)?,
|
||||
E::Quantity { magnitude, unit } => {
|
||||
let alias = self.definition_name(*unit, DefinitionKind::Unit)?;
|
||||
schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[Literal(magnitude), Text(" [@"), Alias(alias), Text("]")],
|
||||
)?;
|
||||
}
|
||||
E::Property(id) => {
|
||||
let alias = self.property_name(*id)?;
|
||||
schedule(self, &mut tasks, &[Text("@"), Alias(alias)])?;
|
||||
}
|
||||
E::Parameter(parameter) => {
|
||||
let calculation = calculation.ok_or_else(|| {
|
||||
self.error("Parameter use requires an owning calculation.")
|
||||
})?;
|
||||
let parameter = *self
|
||||
.parameters
|
||||
.get(&calculation)
|
||||
.and_then(|parameters| parameters.get(parameter))
|
||||
.ok_or_else(|| {
|
||||
self.error("Unknown parameter in owning calculation.")
|
||||
})?;
|
||||
schedule(self, &mut tasks, &[Text("$"), Alias(¶meter.name)])?;
|
||||
}
|
||||
E::List(items) => {
|
||||
if items.len() > a::MAX_LIST_LENGTH {
|
||||
return Err(self.error("List exceeds1024 items."));
|
||||
}
|
||||
self.pending(
|
||||
tasks
|
||||
.len()
|
||||
.saturating_add(items.len().saturating_mul(2))
|
||||
.saturating_add(1),
|
||||
)?;
|
||||
self.text("[")?;
|
||||
tasks.push(Text("]"));
|
||||
for (index, item) in items.iter().enumerate().rev() {
|
||||
tasks.push(Child(item, Parent::Root, depth + 1));
|
||||
if index != 0 {
|
||||
tasks.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
E::Unary { op, operand } => match op {
|
||||
a::UnaryOp::Negate => schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[Text("- "), Child(operand, Parent::Negate, depth + 1)],
|
||||
)?,
|
||||
a::UnaryOp::Absolute | a::UnaryOp::Not => schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Text(if *op == a::UnaryOp::Absolute {
|
||||
"abs("
|
||||
} else {
|
||||
"not("
|
||||
}),
|
||||
Child(operand, Parent::Root, depth + 1),
|
||||
Text(")"),
|
||||
],
|
||||
)?,
|
||||
},
|
||||
E::Binary { op, left, right } => schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Child(left, Parent::Binary(*op, Side::Left), depth + 1),
|
||||
Text(operator(*op).0),
|
||||
Child(right, Parent::Binary(*op, Side::Right), depth + 1),
|
||||
],
|
||||
)?,
|
||||
E::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Text("if("),
|
||||
Child(condition, Parent::Root, depth + 1),
|
||||
Text(", "),
|
||||
Child(when_true, Parent::Root, depth + 1),
|
||||
Text(", "),
|
||||
Child(when_false, Parent::Root, depth + 1),
|
||||
Text(")"),
|
||||
],
|
||||
)?,
|
||||
E::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
let alias =
|
||||
self.definition_name(*definition, DefinitionKind::Calculation)?;
|
||||
let project = self.project;
|
||||
let a::DefinitionBody::Calculation(callee) =
|
||||
&project.definitions[&definition.local()].body
|
||||
else {
|
||||
unreachable!("checked calculation domain")
|
||||
};
|
||||
if arguments.len() != callee.parameters.len()
|
||||
|| callee
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|parameter| !arguments.contains_key(¶meter.id))
|
||||
{
|
||||
return Err(self.error(
|
||||
"Call argument IDs must match the called signature exactly.",
|
||||
));
|
||||
}
|
||||
self.pending(
|
||||
tasks
|
||||
.len()
|
||||
.saturating_add(arguments.len().saturating_mul(4))
|
||||
.saturating_add(1),
|
||||
)?;
|
||||
self.entry()?;
|
||||
self.text("@")?;
|
||||
self.name(alias)?;
|
||||
self.text("(")?;
|
||||
tasks.push(Text(")"));
|
||||
for (index, parameter) in callee.parameters.iter().enumerate().rev() {
|
||||
tasks.push(Child(
|
||||
&arguments[¶meter.id],
|
||||
Parent::Root,
|
||||
depth + 1,
|
||||
));
|
||||
tasks.push(Text(": "));
|
||||
tasks.push(Alias(¶meter.name));
|
||||
if index != 0 {
|
||||
tasks.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
E::Sum { values, initial } => schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Text("sum("),
|
||||
Child(values, Parent::Root, depth + 1),
|
||||
Text(", "),
|
||||
Child(initial, Parent::Root, depth + 1),
|
||||
Text(")"),
|
||||
],
|
||||
)?,
|
||||
E::Power { base, exponent } => {
|
||||
if !(-16..=16).contains(exponent) {
|
||||
return Err(self.error("Power exponent exceeds -16..16."));
|
||||
}
|
||||
schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Text("power("),
|
||||
Child(base, Parent::Root, depth + 1),
|
||||
Text(", "),
|
||||
Exponent(*exponent),
|
||||
Text(")"),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
E::AsKind { kind, value } => {
|
||||
let alias =
|
||||
self.definition_name(*kind, DefinitionKind::QuantityKind)?;
|
||||
schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[
|
||||
Text("as_kind(@"),
|
||||
Alias(alias),
|
||||
Text(", "),
|
||||
Child(value, Parent::Root, depth + 1),
|
||||
Text(")"),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
use super::super::{cursor::Cursor, declarations, lex, lower, model_oracle};
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use a::{
|
||||
AnalysisType, BinaryOp, Binding, DefinitionBody, DefinitionRef, Expr, ParameterId, ScalarType,
|
||||
UnaryOp,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn writer(project: &Project) -> Writer<'_> {
|
||||
Writer::new(project, limits()).unwrap()
|
||||
}
|
||||
fn id(text: &str) -> Id {
|
||||
Id::parse_str(text).unwrap()
|
||||
}
|
||||
fn object(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn number(value: i64) -> Expr {
|
||||
Expr::Number(Exact::from_integer(value))
|
||||
}
|
||||
fn unary(op: UnaryOp, value: Expr) -> Expr {
|
||||
Expr::Unary {
|
||||
op,
|
||||
operand: Box::new(value),
|
||||
}
|
||||
}
|
||||
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
|
||||
Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
fn quantity(value: i64, unit: Id) -> Expr {
|
||||
Expr::Quantity {
|
||||
magnitude: Exact::from_integer(value),
|
||||
unit: DefinitionRef::Local(unit),
|
||||
}
|
||||
}
|
||||
fn budget() -> budget::Budget {
|
||||
budget::Budget::new(budget::Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||
}
|
||||
fn unit(source: &str, budget: &mut budget::Budget) -> Result<lower::Unit, super::super::Error> {
|
||||
let tokens = lex::tokens(source, budget)?;
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut parsed = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
parsed.push(declarations::parse(&mut cursor, 8, None)?);
|
||||
}
|
||||
Ok(lower::Unit {
|
||||
file: 1,
|
||||
declarations: parsed,
|
||||
})
|
||||
}
|
||||
fn merge(mut project: Project, lowered: lower::Lowered) -> Project {
|
||||
project.definitions.extend(lowered.definitions);
|
||||
project.elements.extend(lowered.elements);
|
||||
project
|
||||
}
|
||||
fn parse_typed(project: Project, source: &str) -> Project {
|
||||
let mut budget = budget();
|
||||
let unit = unit(source, &mut budget).unwrap();
|
||||
let lowered = lower::lower(&project, &[unit], &budget).unwrap();
|
||||
merge(project, lowered)
|
||||
}
|
||||
fn skeleton(project: &Project) -> Project {
|
||||
let mut project = project.clone();
|
||||
project.definitions.clear();
|
||||
project
|
||||
.elements
|
||||
.retain(|_, element| !element.kind().is_analysis());
|
||||
project
|
||||
}
|
||||
fn canonical(project: &Project) -> String {
|
||||
writer(project)
|
||||
.typed_declarations()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, text)| text)
|
||||
.collect()
|
||||
}
|
||||
fn read_expression(project: &Project, source: &str) -> Expr {
|
||||
// A staged syntax/lowering wrapper, not a whole public source8 parser.
|
||||
let source = format!(
|
||||
r#"calculation writer_probe "Probe" id "00000000-0000-0000-0000-000000000099" {{
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Number; body {source};
|
||||
}}"#
|
||||
);
|
||||
let mut parsed = parse_typed(project.clone(), &source);
|
||||
let definition = parsed.definitions.remove(&Id::from_u128(0x99)).unwrap();
|
||||
let DefinitionBody::Calculation(calculation) = definition.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
let Binding::Expression(expression) = calculation.body else {
|
||||
panic!("expression")
|
||||
};
|
||||
expression
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_oa_sa_canonical_roundtrips_match_independent_projects() {
|
||||
for (layer, ordinary_source) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
include_str!("../lower/fixtures/ordinary-oa.arcadia"),
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
|
||||
),
|
||||
] {
|
||||
let expected = model_oracle::project(layer);
|
||||
for (alias, name) in model_oracle::NAMES {
|
||||
let id = object(&expected, alias);
|
||||
assert_eq!(
|
||||
expected
|
||||
.definitions
|
||||
.get(&id)
|
||||
.map(|value| value.name.as_str())
|
||||
.or_else(|| expected.elements.get(&id).map(|value| value.name.as_str()))
|
||||
.unwrap(),
|
||||
name
|
||||
);
|
||||
}
|
||||
let text = canonical(&expected);
|
||||
let actual = parse_typed(crate::language::parse(ordinary_source).unwrap(), &text);
|
||||
assert_eq!(actual, expected);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
assert_eq!(canonical(&actual), text);
|
||||
assert!(
|
||||
!text.contains("engineering_property")
|
||||
&& !text.contains("analysis_case")
|
||||
&& !text.contains("analysis_check")
|
||||
);
|
||||
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
|
||||
for word in ["property", "check", "analysis"] {
|
||||
assert!(text.contains(&format!("{prefix}_{word} ")));
|
||||
}
|
||||
}
|
||||
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_declaration_order_and_required_empty_values_are_canonical() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let duration = object(&project, "duration");
|
||||
let total = object(&project, "total_time");
|
||||
if let Some(SharedBody::EngineeringProperty { display_unit, .. }) =
|
||||
project.elements.get_mut(&total).unwrap().body.shared_mut()
|
||||
{
|
||||
*display_unit = None;
|
||||
}
|
||||
let empty_case = object(&project, "unmeasured");
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
|
||||
.elements
|
||||
.get_mut(&empty_case)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
{
|
||||
overrides.clear();
|
||||
}
|
||||
let degraded = object(&project, "degraded");
|
||||
let dispatch = object(&project, "dispatch_time");
|
||||
let second = object(&project, "second");
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
|
||||
.elements
|
||||
.get_mut(°raded)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
{
|
||||
overrides.insert(dispatch, Binding::Expression(quantity(1, second)));
|
||||
}
|
||||
let definitions = [
|
||||
(
|
||||
100,
|
||||
"dimensionless",
|
||||
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
|
||||
dimension: crate::analysis_numeric::Dimension::ZERO,
|
||||
algebra: a::QuantityAlgebra::Linear,
|
||||
}),
|
||||
),
|
||||
(
|
||||
101,
|
||||
"point",
|
||||
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
|
||||
dimension: crate::analysis_numeric::Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: a::QuantityAlgebra::Affine {
|
||||
difference: DefinitionRef::Local(duration),
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
102,
|
||||
"point_unit",
|
||||
DefinitionBody::Unit(a::UnitDefinition {
|
||||
kind: DefinitionRef::Local(Id::from_u128(101)),
|
||||
scale: Exact::from_integer(1),
|
||||
offset: Exact::from_integer(-2),
|
||||
symbol: "\"café\"\n雪".into(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
103,
|
||||
"unused",
|
||||
DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: Vec::new(),
|
||||
result: AnalysisType::List(ScalarType::Number),
|
||||
body: Binding::Unbound,
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (id, alias, body) in definitions {
|
||||
let id = Id::from_u128(id);
|
||||
project.definitions.insert(
|
||||
id,
|
||||
a::Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut writer = writer(&project);
|
||||
let declarations = writer.typed_declarations().unwrap();
|
||||
let ids: Vec<_> = declarations.iter().map(|(id, _)| *id).collect();
|
||||
let expected_ids: Vec<_> = project
|
||||
.definitions
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| element.kind().is_analysis())
|
||||
.map(|element| element.id),
|
||||
)
|
||||
.collect();
|
||||
assert_eq!(ids, expected_ids);
|
||||
let text: String = declarations.into_iter().map(|(_, text)| text).collect();
|
||||
assert!(text.find("@receive_time =").unwrap() < text.find("@dispatch_time =").unwrap());
|
||||
for expected in [
|
||||
"dimension {};",
|
||||
"dimension { time 1; };",
|
||||
"algebra Affine(@duration);",
|
||||
"offset ratio(-2, 1);",
|
||||
"result List(Number);",
|
||||
"body unbound;",
|
||||
"display_unit none;",
|
||||
"overrides {};",
|
||||
"parent none;",
|
||||
"subject none;",
|
||||
"documented_constraint none;",
|
||||
] {
|
||||
assert!(text.contains(expected), "{expected}");
|
||||
}
|
||||
assert_eq!(
|
||||
text.matches("\" id \"").count(),
|
||||
project.definitions.len() + 7
|
||||
);
|
||||
let actual = parse_typed(skeleton(&project), &text);
|
||||
assert_eq!(actual, project);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
assert_eq!(canonical(&actual), text);
|
||||
assert_eq!(
|
||||
self::writer(&project)
|
||||
.override_entry(empty_case, total)
|
||||
.unwrap_err()
|
||||
.field,
|
||||
"overrides"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_expr_variants_and_precedence_preserve_exact_trees() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let second = object(&project, "second");
|
||||
let duration = object(&project, "duration");
|
||||
let property = object(&project, "receive_time");
|
||||
let total = object(&project, "total_time");
|
||||
let combine = object(&project, "combine");
|
||||
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
|
||||
let numeric = AnalysisType::Scalar(ScalarType::Number);
|
||||
let boolean = AnalysisType::Scalar(ScalarType::Boolean);
|
||||
let duration_type = AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration)));
|
||||
let cases = [
|
||||
(Expr::Boolean(true), "true", boolean.clone()),
|
||||
(number(-1), "ratio(-1, 1)", numeric.clone()),
|
||||
(
|
||||
quantity(-1, second),
|
||||
"ratio(-1, 1) [@second]",
|
||||
duration_type.clone(),
|
||||
),
|
||||
(
|
||||
Expr::Property(property),
|
||||
"@receive_time",
|
||||
duration_type.clone(),
|
||||
),
|
||||
(Expr::Parameter(a), "$a", duration_type.clone()),
|
||||
(
|
||||
Expr::List(vec![number(1), number(2)]),
|
||||
"[1, 2]",
|
||||
AnalysisType::List(ScalarType::Number),
|
||||
),
|
||||
(
|
||||
unary(UnaryOp::Absolute, unary(UnaryOp::Negate, number(2))),
|
||||
"abs(- 2)",
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
binary(BinaryOp::Add, number(1), number(2)),
|
||||
"1 + 2",
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
Expr::If {
|
||||
condition: Box::new(Expr::Boolean(true)),
|
||||
when_true: Box::new(number(1)),
|
||||
when_false: Box::new(number(2)),
|
||||
},
|
||||
"if(true, 1, 2)",
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
Expr::Call {
|
||||
definition: DefinitionRef::Local(combine),
|
||||
arguments: BTreeMap::from([(b, quantity(2, second)), (a, quantity(1, second))]),
|
||||
},
|
||||
"@combine(a: 1 [@second], b: 2 [@second])",
|
||||
duration_type.clone(),
|
||||
),
|
||||
(
|
||||
Expr::Sum {
|
||||
values: Box::new(Expr::List(vec![number(1), number(2)])),
|
||||
initial: Box::new(number(0)),
|
||||
},
|
||||
"sum([1, 2], 0)",
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
Expr::Power {
|
||||
base: Box::new(number(2)),
|
||||
exponent: -2,
|
||||
},
|
||||
"power(2, -2)",
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
Expr::AsKind {
|
||||
kind: DefinitionRef::Local(duration),
|
||||
value: Box::new(binary(
|
||||
BinaryOp::Divide,
|
||||
Expr::Power {
|
||||
base: Box::new(quantity(1, second)),
|
||||
exponent: 2,
|
||||
},
|
||||
quantity(1, second),
|
||||
)),
|
||||
},
|
||||
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
|
||||
duration_type,
|
||||
),
|
||||
];
|
||||
project.elements.remove(&object(&project, "budget"));
|
||||
for (expected, golden, value_type) in cases {
|
||||
let parameter = matches!(expected, Expr::Parameter(_));
|
||||
let owner = if parameter { combine } else { total };
|
||||
let field = if parameter { Field::Body } else { Field::Base };
|
||||
let text = writer(&project)
|
||||
.expression(owner, field, &expected, Parent::Root)
|
||||
.unwrap();
|
||||
assert_eq!(text, golden);
|
||||
let actual = read_expression(&project, &text);
|
||||
assert_eq!(actual, expected);
|
||||
let mut candidate = project.clone();
|
||||
if parameter {
|
||||
let DefinitionBody::Calculation(calculation) =
|
||||
&mut candidate.definitions.get_mut(&combine).unwrap().body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
calculation.body = Binding::Expression(actual);
|
||||
} else {
|
||||
let Some(SharedBody::EngineeringProperty {
|
||||
base,
|
||||
value_type: ty,
|
||||
display_unit,
|
||||
..
|
||||
}) = candidate
|
||||
.elements
|
||||
.get_mut(&total)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*base = Binding::Expression(actual);
|
||||
*ty = value_type;
|
||||
*display_unit = None;
|
||||
}
|
||||
a::admit(&candidate, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
}
|
||||
let cases = [
|
||||
(unary(UnaryOp::Negate, number(1)), "- 1"),
|
||||
(unary(UnaryOp::Negate, number(-1)), "- ratio(-1, 1)"),
|
||||
(
|
||||
unary(UnaryOp::Negate, unary(UnaryOp::Negate, number(1))),
|
||||
"- - 1",
|
||||
),
|
||||
(
|
||||
unary(UnaryOp::Negate, binary(BinaryOp::Add, number(1), number(2))),
|
||||
"- (1 + 2)",
|
||||
),
|
||||
(unary(UnaryOp::Not, Expr::Boolean(true)), "not(true)"),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Subtract,
|
||||
number(1),
|
||||
binary(BinaryOp::Subtract, number(2), number(3)),
|
||||
),
|
||||
"1 - (2 - 3)",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Subtract,
|
||||
binary(BinaryOp::Subtract, number(1), number(2)),
|
||||
number(3),
|
||||
),
|
||||
"1 - 2 - 3",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Add,
|
||||
number(1),
|
||||
binary(BinaryOp::Add, number(2), number(3)),
|
||||
),
|
||||
"1 + (2 + 3)",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Multiply,
|
||||
binary(BinaryOp::Add, number(1), number(2)),
|
||||
number(3),
|
||||
),
|
||||
"(1 + 2) * 3",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Equal,
|
||||
binary(BinaryOp::Less, number(1), number(2)),
|
||||
Expr::Boolean(true),
|
||||
),
|
||||
"(1 < 2) == true",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Equal,
|
||||
binary(BinaryOp::Equal, Expr::Boolean(true), Expr::Boolean(false)),
|
||||
Expr::Boolean(true),
|
||||
),
|
||||
"(true == false) == true",
|
||||
),
|
||||
(
|
||||
binary(
|
||||
BinaryOp::Equal,
|
||||
Expr::Boolean(true),
|
||||
binary(BinaryOp::Less, number(1), number(2)),
|
||||
),
|
||||
"true == (1 < 2)",
|
||||
),
|
||||
(Expr::List(Vec::new()), "[]"),
|
||||
];
|
||||
for (expected, golden) in cases {
|
||||
let text = writer(&project)
|
||||
.expression(total, Field::Base, &expected, Parent::Root)
|
||||
.unwrap();
|
||||
assert_eq!(text, golden);
|
||||
assert_eq!(read_expression(&project, &text), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_calls_use_callee_parameter_order_and_qualified_names() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let combine = object(&project, "combine");
|
||||
let total = object(&project, "total_time");
|
||||
let duration = object(&project, "duration");
|
||||
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
|
||||
let DefinitionBody::Calculation(calculation) =
|
||||
&mut project.definitions.get_mut(&combine).unwrap().body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
calculation.parameters.reverse();
|
||||
let other = Id::from_u128(120);
|
||||
project.definitions.insert(
|
||||
other,
|
||||
a::Definition {
|
||||
id: other,
|
||||
alias: "other".into(),
|
||||
name: "Other".into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: vec![a::Parameter {
|
||||
id: a,
|
||||
name: "first".into(),
|
||||
value_type: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(
|
||||
duration,
|
||||
))),
|
||||
}],
|
||||
result: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration))),
|
||||
body: Binding::Expression(Expr::Parameter(a)),
|
||||
}),
|
||||
},
|
||||
);
|
||||
project.definitions.insert(
|
||||
a.0,
|
||||
a::Definition {
|
||||
id: a.0,
|
||||
alias: "collision".into(),
|
||||
name: "Collision".into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::QuantityKind(a::QuantityKindDefinition {
|
||||
dimension: crate::analysis_numeric::Dimension::ZERO,
|
||||
algebra: a::QuantityAlgebra::Linear,
|
||||
}),
|
||||
},
|
||||
);
|
||||
let mut out = writer(&project);
|
||||
let text = out.binding(total, Field::Base).unwrap();
|
||||
assert!(text.starts_with("@combine(b: @dispatch_time, a: @receive_time)"));
|
||||
let call = Expr::Call {
|
||||
definition: DefinitionRef::Local(other),
|
||||
arguments: BTreeMap::from([(a, Expr::Parameter(a))]),
|
||||
};
|
||||
assert_eq!(
|
||||
out.expression(combine, Field::Body, &call, Parent::Root)
|
||||
.unwrap(),
|
||||
"@other(first: $a)"
|
||||
);
|
||||
assert!(out.parameter(combine, a).unwrap().starts_with("input a id"));
|
||||
assert!(
|
||||
out.parameter(other, a)
|
||||
.unwrap()
|
||||
.starts_with("input first id")
|
||||
);
|
||||
assert!(out.parameter(combine, b).unwrap().starts_with("input b id"));
|
||||
let text = canonical(&project);
|
||||
let actual = parse_typed(skeleton(&project), &text);
|
||||
assert_eq!(actual, project);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtree_output_respects_parent_context_and_retained_delimiters() {
|
||||
let project = model_oracle::project(Layer::Oa);
|
||||
let owner = object(&project, "total_time");
|
||||
for replacement in [
|
||||
binary(BinaryOp::Add, number(1), number(2)),
|
||||
number(-1),
|
||||
unary(UnaryOp::Negate, number(1)),
|
||||
] {
|
||||
let text = writer(&project)
|
||||
.expression(owner, Field::Base, &replacement, Parent::Negate)
|
||||
.unwrap();
|
||||
let source = format!("-{text}"); // replace the entire original operand span `(1)` in `-(1)`
|
||||
assert_eq!(
|
||||
read_expression(&project, &source),
|
||||
unary(UnaryOp::Negate, replacement)
|
||||
);
|
||||
}
|
||||
let value = binary(BinaryOp::Subtract, number(2), number(3));
|
||||
let text = writer(&project)
|
||||
.expression(
|
||||
owner,
|
||||
Field::Base,
|
||||
&value,
|
||||
Parent::Binary(BinaryOp::Subtract, Side::Right),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text, "(2 - 3)");
|
||||
assert_eq!(
|
||||
read_expression(&project, &format!("1 - {text}")),
|
||||
binary(BinaryOp::Subtract, number(1), value)
|
||||
);
|
||||
assert_eq!(
|
||||
writer(&project)
|
||||
.value_type(owner, &AnalysisType::List(ScalarType::Boolean))
|
||||
.unwrap(),
|
||||
"List(Boolean)"
|
||||
);
|
||||
let case = object(&project, "unmeasured");
|
||||
let property = object(&project, "receive_time");
|
||||
assert_eq!(
|
||||
writer(&project).override_entry(case, property).unwrap(),
|
||||
"@receive_time = unbound;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_strings_and_exact_literals_use_existing_encodings() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let second = object(&project, "second");
|
||||
let text = "\"\\\n\r\t\u{0000} café 雪";
|
||||
project.definitions.get_mut(&second).unwrap().name = text.into();
|
||||
project.definitions.get_mut(&second).unwrap().description = text.into();
|
||||
let DefinitionBody::Unit(unit) = &mut project.definitions.get_mut(&second).unwrap().body else {
|
||||
panic!()
|
||||
};
|
||||
unit.symbol = text.into();
|
||||
let output = writer(&project).definition(second).unwrap();
|
||||
assert_eq!(output.matches(&crate::language::quote(text)).count(), 3);
|
||||
let actual = parse_typed(skeleton(&project), &canonical(&project));
|
||||
assert_eq!(actual, project);
|
||||
let owner = object(&project, "total_time");
|
||||
for source in ["0", "ratio(-1, 1)", "ratio(3, 2)", "1e100"] {
|
||||
let expression = read_expression(&project, source);
|
||||
let Expr::Number(value) = &expression else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
writer(&project)
|
||||
.expression(owner, Field::Base, &expression, Parent::Root)
|
||||
.unwrap(),
|
||||
value.canonical_literal()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_limits_are_aggregate_exact_and_latched_without_partial_results() {
|
||||
let project = model_oracle::project(Layer::Oa);
|
||||
let owner = object(&project, "total_time");
|
||||
let mut one = Writer::new(
|
||||
&project,
|
||||
Limits {
|
||||
bytes: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
one.expression(owner, Field::Base, &number(1), Parent::Root)
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
let failure = one
|
||||
.expression(owner, Field::Base, &number(2), Parent::Root)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
failure,
|
||||
one.expression(owner, Field::Base, &number(0), Parent::Root)
|
||||
.unwrap_err()
|
||||
);
|
||||
assert_eq!(one.usage().bytes, 1);
|
||||
let mut measured = writer(&project);
|
||||
measured
|
||||
.expression(owner, Field::Base, &number(1), Parent::Root)
|
||||
.unwrap();
|
||||
let usage = measured.usage();
|
||||
for (maximum, pass) in [(usage.entries, true), (usage.entries - 1, false)] {
|
||||
let mut bounded = Writer::new(
|
||||
&project,
|
||||
Limits {
|
||||
entries: maximum,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bounded
|
||||
.expression(owner, Field::Base, &number(1), Parent::Root)
|
||||
.is_ok(),
|
||||
pass
|
||||
);
|
||||
}
|
||||
let output = canonical(&project);
|
||||
let mut exact = Writer::new(
|
||||
&project,
|
||||
Limits {
|
||||
bytes: output.len(),
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(exact.typed_declarations().unwrap().len(), 11);
|
||||
let mut short = Writer::new(
|
||||
&project,
|
||||
Limits {
|
||||
bytes: output.len() - 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(short.typed_declarations().is_err());
|
||||
assert_eq!(canonical(&project), output);
|
||||
let mut quoted = project.clone();
|
||||
let unit_id = object("ed, "second");
|
||||
quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into();
|
||||
let text = writer("ed).definition(unit_id).unwrap();
|
||||
for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] {
|
||||
assert_eq!(
|
||||
Writer::new(
|
||||
"ed,
|
||||
Limits {
|
||||
bytes: maximum,
|
||||
..limits()
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.definition(unit_id)
|
||||
.is_ok(),
|
||||
pass
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Writer::new(
|
||||
&project,
|
||||
Limits {
|
||||
bytes: usize::MAX,
|
||||
..limits()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_lookup_scope_shape_and_depth_failures_are_model_located() {
|
||||
let project = model_oracle::project(Layer::Oa);
|
||||
let owner = object(&project, "total_time");
|
||||
let combine = object(&project, "combine");
|
||||
let duration = object(&project, "duration");
|
||||
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let cases = [
|
||||
Expr::Property(duration),
|
||||
Expr::Property(Id::nil()),
|
||||
Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: DefinitionRef::Local(duration),
|
||||
},
|
||||
Expr::Parameter(a),
|
||||
Expr::Call {
|
||||
definition: DefinitionRef::Local(duration),
|
||||
arguments: BTreeMap::new(),
|
||||
},
|
||||
Expr::Call {
|
||||
definition: DefinitionRef::Local(combine),
|
||||
arguments: BTreeMap::new(),
|
||||
},
|
||||
Expr::Call {
|
||||
definition: DefinitionRef::Local(combine),
|
||||
arguments: BTreeMap::from([(ParameterId(Id::nil()), number(1)), (a, number(2))]),
|
||||
},
|
||||
Expr::Power {
|
||||
base: Box::new(number(1)),
|
||||
exponent: 17,
|
||||
},
|
||||
Expr::List((0..1025).map(|_| number(1)).collect()),
|
||||
];
|
||||
for expression in cases {
|
||||
let error = writer(&project)
|
||||
.expression(owner, Field::Base, &expression, Parent::Root)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.owner, Some(owner));
|
||||
assert_eq!(error.field, "base");
|
||||
}
|
||||
assert!(
|
||||
writer(&project)
|
||||
.expression(
|
||||
combine,
|
||||
Field::Body,
|
||||
&Expr::Parameter(ParameterId(Id::nil())),
|
||||
Parent::Root
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(writer(&project).binding(owner, Field::Body).is_err());
|
||||
assert!(
|
||||
writer(&project)
|
||||
.analysis_element(object(&project, "respond"))
|
||||
.is_err()
|
||||
);
|
||||
let mut deep = number(-1);
|
||||
for _ in 0..128 {
|
||||
deep = unary(UnaryOp::Negate, deep);
|
||||
}
|
||||
assert!(
|
||||
writer(&project)
|
||||
.expression(owner, Field::Base, &deep, Parent::Root)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("depth")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negate_override_roundtrip_preserves_depth128_within_frame256() {
|
||||
let original = model_oracle::project(Layer::Oa);
|
||||
let prefix = format!("{}ratio(-1, 1)", "- ".repeat(127));
|
||||
let grouped = format!("{}ratio(-1, 1){}", "-(".repeat(127), ")".repeat(127));
|
||||
let property = r#"oa_property measure "Measure" id "00000000-0000-0000-0000-000000000130" { subject @respond; type Number; base unbound; }"#;
|
||||
let case = |expression: &str| {
|
||||
format!(
|
||||
r#"oa_analysis measured "Measured" id "00000000-0000-0000-0000-000000000131" {{ base ModelBaseline; overrides {{ @measure = {expression}; }}; }}"#
|
||||
)
|
||||
};
|
||||
let calculation = |expression: &str| {
|
||||
format!(
|
||||
r#"calculation measured_calc "Measured calculation" id "00000000-0000-0000-0000-000000000132" {{ result Number; body {expression}; }}"#
|
||||
)
|
||||
};
|
||||
for (source, frames) in [
|
||||
(case(&prefix), 130),
|
||||
(calculation(&prefix), 129),
|
||||
(calculation(&grouped), 256),
|
||||
] {
|
||||
let mut budget = budget();
|
||||
unit(&source, &mut budget).unwrap();
|
||||
assert_eq!(budget.usage.deepest_frame, frames);
|
||||
}
|
||||
let mut failed_budget = budget();
|
||||
let error = unit(&case(&grouped), &mut failed_budget).err().unwrap();
|
||||
assert!(error.message.contains("syntax frames"));
|
||||
assert_eq!(failed_budget.usage.deepest_frame, 256);
|
||||
let project = parse_typed(original, &format!("{property}\n{}", case(&prefix)));
|
||||
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
let case_id = Id::from_u128(0x131);
|
||||
let property_id = Id::from_u128(0x130);
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = project.elements[&case_id].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let Binding::Expression(root) = &overrides[&property_id] else {
|
||||
panic!()
|
||||
};
|
||||
let mut current = root;
|
||||
let mut negates = 0;
|
||||
while let Expr::Unary {
|
||||
op: UnaryOp::Negate,
|
||||
operand,
|
||||
} = current
|
||||
{
|
||||
current = operand;
|
||||
negates += 1;
|
||||
}
|
||||
assert_eq!(negates, 127);
|
||||
assert_eq!(current, &number(-1));
|
||||
let text = writer(&project).analysis_element(case_id).unwrap();
|
||||
assert!(text.contains(&prefix));
|
||||
let mut budget = budget();
|
||||
unit(&text, &mut budget).unwrap();
|
||||
assert_eq!(budget.usage.deepest_frame, 130);
|
||||
let mut before_case = project.clone();
|
||||
before_case.elements.remove(&case_id);
|
||||
let actual = parse_typed(before_case, &text);
|
||||
assert_eq!(actual, project);
|
||||
assert_eq!(writer(&actual).analysis_element(case_id).unwrap(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_writer_preserves_width1024_and_empty_call_topology() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let owner = object(&project, "total_time");
|
||||
let expected = Expr::List((0..1024).map(|_| number(1)).collect());
|
||||
let text = writer(&project)
|
||||
.expression(owner, Field::Base, &expected, Parent::Root)
|
||||
.unwrap();
|
||||
assert_eq!(read_expression(&project, &text), expected);
|
||||
let empty = Id::from_u128(140);
|
||||
project.definitions.insert(
|
||||
empty,
|
||||
a::Definition {
|
||||
id: empty,
|
||||
alias: "empty".into(),
|
||||
name: "Empty".into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: Vec::new(),
|
||||
result: AnalysisType::Scalar(ScalarType::Number),
|
||||
body: Binding::Unbound,
|
||||
}),
|
||||
},
|
||||
);
|
||||
let call = Expr::Call {
|
||||
definition: DefinitionRef::Local(empty),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
let text = writer(&project)
|
||||
.expression(owner, Field::Base, &call, Parent::Root)
|
||||
.unwrap();
|
||||
assert_eq!(text, "@empty()");
|
||||
assert_eq!(read_expression(&project, &text), call);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! One raw lexical scan for legacy adapters and staged typed source syntax.
|
||||
//! Numbers remain source spans; only consumers choose a numeric domain.
|
||||
use super::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Class {
|
||||
Word,
|
||||
Number,
|
||||
String,
|
||||
Punctuation,
|
||||
Whitespace,
|
||||
Comment,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Token {
|
||||
pub class: Class,
|
||||
pub span: Span,
|
||||
/// Entire token belongs to the old greedy numeric-token alphabet.
|
||||
pub legacy_number_part: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Scanner<'a> {
|
||||
source: &'a str,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> Scanner<'a> {
|
||||
pub fn new(source: &'a str) -> Self {
|
||||
Self { source, cursor: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Scanner<'_> {
|
||||
type Item = Token;
|
||||
|
||||
fn next(&mut self) -> Option<Token> {
|
||||
let source = self.source;
|
||||
let bytes = source.as_bytes();
|
||||
let start = self.cursor;
|
||||
let first = *bytes.get(start)?;
|
||||
let mut legacy_number_part = false;
|
||||
let class = if matches!(first, b' ' | b'\t' | b'\r' | b'\n') {
|
||||
self.cursor += 1;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
Class::Whitespace
|
||||
} else if bytes[start..].starts_with(b"//") {
|
||||
self.cursor += 2;
|
||||
while bytes.get(self.cursor).is_some_and(|b| *b != b'\n') {
|
||||
self.cursor += 1;
|
||||
}
|
||||
Class::Comment
|
||||
} else if first.is_ascii_alphabetic() || first == b'_' {
|
||||
self.cursor += 1;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
legacy_number_part = true;
|
||||
Class::Word
|
||||
} else if first.is_ascii_digit() {
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let mut valid = true;
|
||||
if bytes.get(self.cursor) == Some(&b'.') {
|
||||
self.cursor += 1;
|
||||
let fraction = self.cursor;
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
valid &= self.cursor != fraction;
|
||||
}
|
||||
if matches!(bytes.get(self.cursor), Some(b'e' | b'E')) {
|
||||
self.cursor += 1;
|
||||
if matches!(bytes.get(self.cursor), Some(b'+' | b'-')) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let exponent = self.cursor;
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
valid &= self.cursor != exponent;
|
||||
}
|
||||
if bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'.'))
|
||||
{
|
||||
valid = false;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.'))
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
legacy_number_part = true;
|
||||
if valid { Class::Number } else { Class::Error }
|
||||
} else if first == b'"' {
|
||||
self.cursor += 1;
|
||||
let mut escaped = false;
|
||||
let mut closed = false;
|
||||
while self.cursor < bytes.len() {
|
||||
let ch = source[self.cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("UTF-8 boundary");
|
||||
self.cursor += ch.len_utf8();
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed && serde_json::from_str::<String>(&source[start..self.cursor]).is_ok() {
|
||||
Class::String
|
||||
} else {
|
||||
Class::Error
|
||||
}
|
||||
} else if b"{}();,[]:=@$+-*/<>!".contains(&first) {
|
||||
self.cursor += 1;
|
||||
if matches!(first, b'<' | b'>' | b'=' | b'!') && bytes.get(self.cursor) == Some(&b'=') {
|
||||
self.cursor += 1;
|
||||
}
|
||||
legacy_number_part = matches!(first, b'+' | b'-');
|
||||
Class::Punctuation
|
||||
} else {
|
||||
self.cursor += source[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("UTF-8 boundary")
|
||||
.len_utf8();
|
||||
legacy_number_part = first == b'.';
|
||||
Class::Error
|
||||
};
|
||||
Some(Token {
|
||||
class,
|
||||
span: Span {
|
||||
start,
|
||||
end: self.cursor,
|
||||
},
|
||||
legacy_number_part,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the existing public token shapes, including greedy signed JSON
|
||||
/// numbers and single-character rejection of newer expression operators.
|
||||
pub(super) fn legacy(source: &str) -> Vec<super::TokenSpan> {
|
||||
use super::{TokenClass, TokenSpan};
|
||||
let mut scanner = Scanner::new(source).peekable();
|
||||
let mut out = Vec::new();
|
||||
while let Some(token) = scanner.next() {
|
||||
let mut span = token.span;
|
||||
let first = source.as_bytes()[span.start];
|
||||
if first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.') {
|
||||
while scanner
|
||||
.peek()
|
||||
.is_some_and(|next| next.legacy_number_part && next.span.start == span.end)
|
||||
{
|
||||
span.end = scanner.next().expect("peeked token").span.end;
|
||||
}
|
||||
let class = if serde_json::from_str::<serde_json::Number>(&source[span.start..span.end])
|
||||
.is_ok()
|
||||
{
|
||||
TokenClass::Number
|
||||
} else {
|
||||
TokenClass::Error
|
||||
};
|
||||
out.push(TokenSpan { class, span });
|
||||
continue;
|
||||
}
|
||||
let class = match token.class {
|
||||
Class::Word => {
|
||||
let word = &source[span.start..span.end];
|
||||
if super::reserved(word) || matches!(word, "true" | "false" | "none") {
|
||||
TokenClass::Keyword
|
||||
} else {
|
||||
TokenClass::Identifier
|
||||
}
|
||||
}
|
||||
Class::String => TokenClass::String,
|
||||
Class::Whitespace => TokenClass::Whitespace,
|
||||
Class::Comment => TokenClass::Comment,
|
||||
Class::Punctuation => {
|
||||
// The raw scanner joins comparison operators. Legacy syntax
|
||||
// classified their ASCII characters individually.
|
||||
for at in span.start..span.end {
|
||||
let class = if b"{}();,[]:=@".contains(&source.as_bytes()[at]) {
|
||||
TokenClass::Punctuation
|
||||
} else {
|
||||
TokenClass::Error
|
||||
};
|
||||
out.push(TokenSpan {
|
||||
class,
|
||||
span: Span {
|
||||
start: at,
|
||||
end: at + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Class::Error => TokenClass::Error,
|
||||
Class::Number => unreachable!("number tokens start with a digit"),
|
||||
};
|
||||
out.push(TokenSpan { class, span });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{TokenClass, tokenize};
|
||||
|
||||
#[test]
|
||||
fn legacy_adapter_retains_authored_numeric_operator_and_trivia_tokens() {
|
||||
use TokenClass::*;
|
||||
let cases: &[(&str, &[(TokenClass, &str)])] = &[
|
||||
(
|
||||
"-1.5 1e-2 -0",
|
||||
&[
|
||||
(Number, "-1.5"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1e-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "-0"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"1-2 1 -2 1 - 2",
|
||||
&[
|
||||
(Error, "1-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1"),
|
||||
(Whitespace, " "),
|
||||
(Number, "-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1"),
|
||||
(Whitespace, " "),
|
||||
(Error, "-"),
|
||||
(Whitespace, " "),
|
||||
(Number, "2"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"+1 .5 1_0 0x1 1e 1e+",
|
||||
&[
|
||||
(Error, "+1"),
|
||||
(Whitespace, " "),
|
||||
(Error, ".5"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1_0"),
|
||||
(Whitespace, " "),
|
||||
(Error, "0x1"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1e"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1e+"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"<= != == >= / * $ !",
|
||||
&[
|
||||
(Error, "<"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, "!"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Punctuation, "="),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, ">"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, "/"),
|
||||
(Whitespace, " "),
|
||||
(Error, "*"),
|
||||
(Whitespace, " "),
|
||||
(Error, "$"),
|
||||
(Whitespace, " "),
|
||||
(Error, "!"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"// π\r\n\"雪\" @alias",
|
||||
&[
|
||||
(Comment, "// π\r"),
|
||||
(Whitespace, "\n"),
|
||||
(String, "\"雪\""),
|
||||
(Whitespace, " "),
|
||||
(Punctuation, "@"),
|
||||
(Keyword, "alias"),
|
||||
],
|
||||
),
|
||||
("\"incomplete 雪", &[(Error, "\"incomplete 雪")]),
|
||||
("\"bad\\q\"", &[(Error, "\"bad\\q\"")]),
|
||||
(
|
||||
"雪\u{b}a",
|
||||
&[(Error, "雪"), (Error, "\u{b}"), (Identifier, "a")],
|
||||
),
|
||||
(
|
||||
"unit calculation quantity_kind if and List Number",
|
||||
&[
|
||||
(Identifier, "unit"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "calculation"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "quantity_kind"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "if"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "and"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "List"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "Number"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (source, expected) in cases {
|
||||
let actual = tokenize(source);
|
||||
assert_eq!(actual.len(), expected.len(), "{source:?}");
|
||||
let mut offset = 0;
|
||||
for (token, (class, spelling)) in actual.iter().zip(*expected) {
|
||||
assert_eq!(token.class, *class, "{source:?} at {offset}");
|
||||
assert_eq!(token.span.start, offset);
|
||||
offset += spelling.len();
|
||||
assert_eq!(token.span.end, offset);
|
||||
assert_eq!(&source[token.span.start..token.span.end], *spelling);
|
||||
}
|
||||
assert_eq!(offset, source.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,8 @@ pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits: ParseLimits::UNLIMITED,
|
||||
#[cfg(test)]
|
||||
mixed: None,
|
||||
};
|
||||
let ast = parser.value().ok()?;
|
||||
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Convert only source3 lowering scratch state into its frozen migration input.
|
||||
//! This is not a current-to-legacy exporter: fields outside that scratch state
|
||||
//! must reject instead of being silently discarded.
|
||||
use crate::Project;
|
||||
use crate::persistence::{FileError, v3};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn from_scratch(
|
||||
project: Project,
|
||||
legacy_elements: Map<String, Value>,
|
||||
) -> Result<v3::Project3, FileError> {
|
||||
// No `..`: a future Project field requires an explicit compatibility decision.
|
||||
let Project {
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
definitions,
|
||||
elements,
|
||||
relations,
|
||||
diagrams,
|
||||
artifact_links,
|
||||
traces,
|
||||
} = project;
|
||||
if schema_version != 3 {
|
||||
return Err(FileError::Invalid(
|
||||
"Expected source3 lowering state.".into(),
|
||||
));
|
||||
}
|
||||
if !definitions.is_empty() || !elements.is_empty() {
|
||||
return Err(FileError::Invalid(
|
||||
"Source3 lowering state cannot contain current definitions or elements.".into(),
|
||||
));
|
||||
}
|
||||
Ok(v3::Project3 {
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
elements: serde_json::from_value(Value::Object(legacy_elements))?,
|
||||
// Keep the frozen nested contracts, including strict trace baselines.
|
||||
relations: serde_json::from_value(serde_json::to_value(relations)?)?,
|
||||
diagrams,
|
||||
artifact_links,
|
||||
traces: serde_json::from_value(serde_json::to_value(traces)?)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
use super::*;
|
||||
use crate::{Id, language, persistence};
|
||||
use serde_json::json;
|
||||
|
||||
const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia");
|
||||
const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json");
|
||||
const PROSE: &str = r#"arcadia 3;
|
||||
project review "Review" id "00000000-0000-0000-0000-000000000001" {
|
||||
oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {}
|
||||
sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {}
|
||||
trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" {
|
||||
oa @reviewed_activity; sa @function; kind Refinement;
|
||||
baseline {
|
||||
oa { name "Approved OA"; description "First\nΩ rationale"; };
|
||||
sa { name "Approved SA"; description "Prior SA prose"; };
|
||||
};
|
||||
}
|
||||
}"#;
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn prose_json() -> Value {
|
||||
json!({
|
||||
"schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"",
|
||||
"elements": {
|
||||
id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}},
|
||||
id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}}
|
||||
},
|
||||
"relations":{},"diagrams":{},"artifact_links":{},
|
||||
"traces": {id(4).to_string():{
|
||||
"id":id(4),"alias":"review_link","name":"Review link","description":"",
|
||||
"oa":id(2),"sa":id(3),"kind":"Refinement",
|
||||
"baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}}
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_source3_and_json3_have_complete_model_and_migration_parity() {
|
||||
let source = language::parse_with_report(SOURCE3).unwrap();
|
||||
let json = persistence::decode_with_report(JSON3).unwrap();
|
||||
assert_eq!(source.project, json.project);
|
||||
assert_eq!(source.migration, json.migration);
|
||||
assert!(source.project.definitions.is_empty());
|
||||
let report = source.migration.unwrap();
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(3, crate::SCHEMA_VERSION)
|
||||
);
|
||||
// This tests direct legacy lowering. Canonical source8 reopening remains a
|
||||
// separate, unchanged public integration gate.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() {
|
||||
let original: Value = serde_json::from_slice(JSON3).unwrap();
|
||||
for definitions in [
|
||||
json!({}),
|
||||
Value::Null,
|
||||
json!({id(90).to_string(): {"name":"new"}}),
|
||||
] {
|
||||
let mut bad = original.clone();
|
||||
bad["definitions"] = definitions;
|
||||
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(error.contains("unknown field `definitions`"), "{error}");
|
||||
}
|
||||
let mut bad = original;
|
||||
bad["unrecognized"] = json!(true);
|
||||
assert!(
|
||||
persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("unknown field `unrecognized`")
|
||||
);
|
||||
let raw = std::str::from_utf8(JSON3).unwrap();
|
||||
let duplicate = raw.replacen(
|
||||
"\"schema_version\": 3",
|
||||
"\"schema_version\": 3, \"schema_version\": 3",
|
||||
1,
|
||||
);
|
||||
assert_ne!(duplicate, raw);
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("duplicate")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_review_prose_survives_without_admitting_semantic_fields() {
|
||||
let source = language::parse_with_report(PROSE).unwrap();
|
||||
let json =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap();
|
||||
assert_eq!(source.project, json.project);
|
||||
assert_eq!(source.migration, json.migration);
|
||||
let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap();
|
||||
assert_eq!(baseline.oa.name, "Approved OA");
|
||||
assert_eq!(baseline.oa.description, "First\nΩ rationale");
|
||||
assert_eq!(baseline.sa.description, "Prior SA prose");
|
||||
assert!(baseline.semantic.is_none());
|
||||
for semantic in [Value::Null, json!({"facets":[]})] {
|
||||
let mut bad = prose_json();
|
||||
bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic;
|
||||
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(error.contains("semantic"), "{error}");
|
||||
}
|
||||
for field in ["semantic none;", "semantic { facets []; };"] {
|
||||
let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}"));
|
||||
let errors = language::parse(&bad).err().unwrap();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("require schema/source5")),
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_bridge_refuses_to_discard_current_only_data() {
|
||||
let mut empty = Project::empty();
|
||||
empty.schema_version = 3;
|
||||
assert!(from_scratch(empty.clone(), Map::new()).is_ok());
|
||||
let mut wrong_version = empty.clone();
|
||||
wrong_version.schema_version = crate::SCHEMA_VERSION;
|
||||
assert!(from_scratch(wrong_version, Map::new()).is_err());
|
||||
let mut definition = empty;
|
||||
definition.definitions.insert(
|
||||
id(90),
|
||||
crate::analysis::Definition {
|
||||
id: id(90),
|
||||
alias: "dimensionless".into(),
|
||||
name: "Dimensionless".into(),
|
||||
description: String::new(),
|
||||
body: crate::analysis::DefinitionBody::QuantityKind(
|
||||
crate::analysis::QuantityKindDefinition {
|
||||
dimension: crate::analysis_numeric::Dimension::ZERO,
|
||||
algebra: crate::analysis::QuantityAlgebra::Linear,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
from_scratch(definition, Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("definitions or elements")
|
||||
);
|
||||
let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap())
|
||||
.unwrap()
|
||||
.project;
|
||||
current.schema_version = 3;
|
||||
assert!(
|
||||
from_scratch(current.clone(), Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("definitions or elements")
|
||||
);
|
||||
current.elements.clear();
|
||||
current
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() });
|
||||
assert!(
|
||||
from_scratch(current, Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("semantic")
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
//! Prepared outline tests. Explicit compatibility tests additionally exercise
|
||||
//! the existing parsers/analyze; the outline entry itself never calls them.
|
||||
use super::*;
|
||||
|
||||
fn limits() -> OutlineLimits {
|
||||
OutlineLimits {
|
||||
max_source_bytes: 1024 * 1024,
|
||||
max_tokens: 100_000,
|
||||
max_declarations: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
|
||||
result
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.declaration.alias == alias)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
|
||||
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
|
||||
let before = source.as_bytes().to_vec();
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source.as_bytes(), before);
|
||||
assert_eq!(result.source.as_ptr(), source.as_ptr());
|
||||
let d = declaration(&result, "responder");
|
||||
assert_eq!(text(source, d.kind_span), "oa_actor");
|
||||
assert_eq!(text(source, d.alias_span), "responder");
|
||||
assert_eq!(
|
||||
text(source, d.id_literal_span.unwrap()),
|
||||
"\"\\u00300000000-0000-0000-0000-000000000001\""
|
||||
);
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
|
||||
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
|
||||
let mut end = 0;
|
||||
for token in &result.tokens {
|
||||
assert_eq!(token.span.start, end);
|
||||
assert!(token.span.start < token.span.end);
|
||||
assert!(source.is_char_boundary(token.span.start));
|
||||
assert!(source.is_char_boundary(token.span.end));
|
||||
end = token.span.end;
|
||||
}
|
||||
assert_eq!(end, source.len());
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_id_at_eof_is_inside_the_declaration_extent() {
|
||||
for source in [
|
||||
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
|
||||
"node placed id \"00000000-0000-0000-0000-000000000001\"",
|
||||
] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
let literal = d.id_literal_span.unwrap();
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert!(d.declaration.span.start <= literal.start);
|
||||
assert!(literal.end <= d.declaration.span.end);
|
||||
}
|
||||
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(d.id_literal_span, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
|
||||
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
|
||||
let first = outline(source, limits()).unwrap();
|
||||
let second = outline(source, limits()).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
first
|
||||
.declarations
|
||||
.iter()
|
||||
.all(|d| d.declaration.id.is_none())
|
||||
);
|
||||
assert_eq!(declaration(&first, "missing").id_literal_span, None);
|
||||
assert_eq!(declaration(&first, "historical").id_literal_span, None);
|
||||
assert_eq!(
|
||||
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
|
||||
"\"not-a-uuid\""
|
||||
);
|
||||
// Balanced tokens are not a successful syntax/semantic validation claim.
|
||||
assert!(!first.has_lexical_errors);
|
||||
assert!(!first.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
|
||||
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source, source);
|
||||
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
|
||||
assert!(result.has_lexical_errors);
|
||||
assert!(result.has_unmatched_braces);
|
||||
let ready = declaration(&result, "ready");
|
||||
assert_eq!(
|
||||
text(source, ready.declaration.span),
|
||||
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "outer").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "pending").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
|
||||
assert!(unmatched_close.has_unmatched_braces);
|
||||
assert!(!unmatched_close.has_lexical_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
|
||||
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 1);
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
// There is deliberately no `valid` flag: the known-complete fact is scanning
|
||||
// these bytes, not the missing body becoming legal or the draft being applied.
|
||||
assert_eq!(result.source, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
|
||||
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 5);
|
||||
let duplicates: Vec<_> = result
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|d| d.declaration.alias == "oa_activity")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
|
||||
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
|
||||
assert!(
|
||||
result
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
|
||||
);
|
||||
assert_eq!(
|
||||
text(source, declaration(&result, "placement").kind_span),
|
||||
"node"
|
||||
);
|
||||
assert_eq!(declaration(&result, "placement").declaration.id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_refuse_before_returning_a_partial_outline() {
|
||||
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
|
||||
let admitted = outline(source, limits()).unwrap();
|
||||
let exact = OutlineLimits {
|
||||
max_source_bytes: source.len(),
|
||||
max_tokens: admitted.tokens.len(),
|
||||
max_declarations: 2,
|
||||
};
|
||||
assert_eq!(outline(source, exact).unwrap(), admitted);
|
||||
for resource in [
|
||||
OutlineResource::SourceBytes,
|
||||
OutlineResource::Tokens,
|
||||
OutlineResource::Declarations,
|
||||
] {
|
||||
let mut lower = exact;
|
||||
match resource {
|
||||
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
|
||||
OutlineResource::Tokens => lower.max_tokens -= 1,
|
||||
OutlineResource::Declarations => lower.max_declarations -= 1,
|
||||
}
|
||||
let error = outline(source, lower).unwrap_err();
|
||||
assert_eq!(error.resource, resource);
|
||||
assert!(error.observed_at_least > error.limit);
|
||||
assert!(source.is_char_boundary(error.offset));
|
||||
if resource == OutlineResource::Declarations {
|
||||
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
|
||||
}
|
||||
}
|
||||
let empty = outline(
|
||||
"",
|
||||
OutlineLimits {
|
||||
max_source_bytes: 0,
|
||||
max_tokens: 0,
|
||||
max_declarations: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
|
||||
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_source_bytes: 4,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::SourceBytes);
|
||||
assert_eq!(error.offset, 0);
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_tokens: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Tokens);
|
||||
assert_eq!(error.offset, unicode.find('\n').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
|
||||
let old = "oa_actor a \"A\" {}\nsource @a;";
|
||||
let current = format!("// 航海 🚒\r\n{old}");
|
||||
let old_outline = outline(old, limits()).unwrap();
|
||||
let current_outline = outline(¤t, limits()).unwrap();
|
||||
let shift = current.len() - old.len();
|
||||
let old_role = &old_outline.declarations[0];
|
||||
let current_role = ¤t_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);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis;
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
|
||||
@@ -75,6 +75,7 @@ impl Project4 {
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
|
||||
//! Unchanged nested records are shared only after the closed old-kind check.
|
||||
use crate::persistence::FileError;
|
||||
use crate::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project7 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
impl Project7 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
if !(5..=7).contains(&self.schema_version) {
|
||||
return Err(FileError::Invalid(
|
||||
"Expected frozen schema5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
reject_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: self.schema_version,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self.traces,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn reject_elements<'a>(
|
||||
elements: impl Iterator<Item = &'a Element>,
|
||||
) -> Result<(), FileError> {
|
||||
for element in elements {
|
||||
if element.kind().is_analysis() {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"{:?} requires schema/source 8.",
|
||||
element.kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const SCHEMA_VERSION: u32 = 8;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -64,9 +64,12 @@ pub enum Kind {
|
||||
Terminate,
|
||||
Event,
|
||||
Constraint,
|
||||
EngineeringProperty,
|
||||
AnalysisCase,
|
||||
AnalysisCheck,
|
||||
}
|
||||
impl Kind {
|
||||
pub const ALL: [Self; 39] = [
|
||||
pub const ALL: [Self; 42] = [
|
||||
Self::Entity,
|
||||
Self::Actor,
|
||||
Self::Role,
|
||||
@@ -106,6 +109,9 @@ impl Kind {
|
||||
Self::Terminate,
|
||||
Self::Event,
|
||||
Self::Constraint,
|
||||
Self::EngineeringProperty,
|
||||
Self::AnalysisCase,
|
||||
Self::AnalysisCheck,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -148,6 +154,9 @@ impl Kind {
|
||||
Self::Terminate => "Terminate",
|
||||
Self::Event => "Event",
|
||||
Self::Constraint => "Constraint",
|
||||
Self::EngineeringProperty => "Engineering property",
|
||||
Self::AnalysisCase => "Analysis case",
|
||||
Self::AnalysisCheck => "Analysis check",
|
||||
}
|
||||
}
|
||||
pub fn is_performer(self) -> bool {
|
||||
@@ -156,6 +165,9 @@ impl Kind {
|
||||
Self::Entity | Self::Actor | Self::System | Self::SystemActor
|
||||
)
|
||||
}
|
||||
pub fn is_analysis(self) -> bool {
|
||||
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
|
||||
}
|
||||
pub fn is_work(self) -> bool {
|
||||
matches!(self, Self::Activity | Self::Function)
|
||||
}
|
||||
@@ -652,6 +664,23 @@ pub enum SharedBody {
|
||||
expression: String,
|
||||
constrained: Vec<Id>,
|
||||
},
|
||||
EngineeringProperty {
|
||||
subject: crate::analysis::AnalysisSubject,
|
||||
value_type: crate::analysis::AnalysisType,
|
||||
display_unit: Option<crate::analysis::DefinitionRef>,
|
||||
base: crate::analysis::Binding,
|
||||
},
|
||||
AnalysisCase {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
base: crate::analysis::BaselineBase,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
overrides: BTreeMap<Id, crate::analysis::Binding>,
|
||||
},
|
||||
AnalysisCheck {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
documented_constraint: Option<Id>,
|
||||
predicate: crate::analysis::Binding,
|
||||
},
|
||||
}
|
||||
impl Element {
|
||||
pub fn layer(&self) -> Layer {
|
||||
@@ -752,7 +781,8 @@ impl ElementBody {
|
||||
}
|
||||
}
|
||||
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
|
||||
if !kind.supports(layer) {
|
||||
// Analysis creation requires explicitly authored types and bindings.
|
||||
if !kind.supports(layer) || kind.is_analysis() {
|
||||
return None;
|
||||
}
|
||||
Some(match kind {
|
||||
@@ -864,6 +894,9 @@ impl SharedBody {
|
||||
Self::Region => Kind::Region,
|
||||
Self::State { kind, .. } => kind.kind(),
|
||||
Self::Constraint { .. } => Kind::Constraint,
|
||||
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
|
||||
Self::AnalysisCase { .. } => Kind::AnalysisCase,
|
||||
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1625,6 +1658,8 @@ pub struct Project {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
@@ -1717,6 +1752,7 @@ impl Project {
|
||||
alias: "mission".into(),
|
||||
name: "Untitled mission".into(),
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1726,6 +1762,7 @@ impl Project {
|
||||
}
|
||||
pub fn identities(&self) -> Vec<(Id, &str)> {
|
||||
let mut out = vec![(self.id, self.alias.as_str())];
|
||||
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
|
||||
@@ -1783,6 +1820,9 @@ impl Project {
|
||||
if id == self.id {
|
||||
return &self.name;
|
||||
}
|
||||
if let Some(v) = self.definitions.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
if let Some(v) = self.elements.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
mod json_codec;
|
||||
#[cfg(test)]
|
||||
#[path = "persistence/source_save_tests.rs"]
|
||||
mod source_save_tests;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
@@ -9,6 +13,8 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
#[path = "migration_v7.rs"]
|
||||
pub(crate) mod v7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -130,6 +136,19 @@ pub fn decode_limited(
|
||||
pub fn decode_with_report_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report_profile(
|
||||
bytes,
|
||||
limits,
|
||||
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn decode_with_report_profile(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
@@ -138,65 +157,43 @@ pub fn decode_with_report_limited(
|
||||
)));
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
json_codec::preflight(bytes, profile)?;
|
||||
let version = json_codec::version(bytes)?;
|
||||
if version.is_some_and(|version| version <= 6) {
|
||||
let header = json_codec::legacy(bytes)?;
|
||||
let result = check_legacy_header(&header, version);
|
||||
json_codec::discard_value(header);
|
||||
result?;
|
||||
}
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
match version {
|
||||
Some(1) => {
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
let old = json_codec::legacy(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(6) => migrate_v6(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(7) => migrate_v7(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(8) => DecodeResult {
|
||||
project: json_codec::current(bytes, profile, depth_128)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -225,6 +222,38 @@ pub fn decode_with_report_limited(
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
|
||||
reject_source7_fields(header).map_err(FileError::Invalid)?;
|
||||
if version.is_some_and(|version| version <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Versions 4 and 5 share unchanged typed records, but their constructor set
|
||||
/// remains frozen. Apply this before normalizing the advertised version.
|
||||
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
|
||||
@@ -348,6 +377,7 @@ pub(crate) fn migrate_v5(
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
@@ -368,48 +398,49 @@ pub(crate) fn migrate_v5(
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str)
|
||||
== Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
pending.extend(fields.values().rev());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
serde_json::Value::Array(values) => {
|
||||
pending.extend(values.iter().rev());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -420,6 +451,7 @@ pub(crate) fn migrate_v6(
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
@@ -429,6 +461,30 @@ pub(crate) fn migrate_v6(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v7(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 7 {
|
||||
return Err(FileError::Invalid("Expected schema_version 7.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(7)),
|
||||
})
|
||||
}
|
||||
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
|
||||
if !project.definitions.is_empty() {
|
||||
return Err(FileError::Invalid(
|
||||
"Definitions require schema/source 8.".into(),
|
||||
));
|
||||
}
|
||||
v7::reject_elements(project.elements.values())
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
@@ -846,6 +902,9 @@ pub(crate) mod v1 {
|
||||
}
|
||||
|
||||
fn validate_project(p: &Project) -> Result<(), FileError> {
|
||||
json_codec::document_stack(|| validate_project_inner(p))
|
||||
}
|
||||
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
|
||||
if let Some(i) = validation::validate(p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
@@ -861,7 +920,10 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
load_with_report(path).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report(&std::fs::read(path)?)
|
||||
decode_with_report(&json_codec::read_bounded(
|
||||
path,
|
||||
json_codec::MAX_DOCUMENT_BYTES,
|
||||
)?)
|
||||
}
|
||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||
@@ -874,19 +936,69 @@ pub fn save_checked(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
save_checked_profile(
|
||||
path,
|
||||
p,
|
||||
expected,
|
||||
json_codec::Profile::DOCUMENT,
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn save_checked_profile(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
|
||||
crate::language::format(project).into_bytes()
|
||||
})
|
||||
}
|
||||
// Keep the source writer replaceable only at this private seam so regressions
|
||||
// can prove that malformed or lossy output never reaches filesystem staging.
|
||||
fn save_checked_profile_with_source(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
source: impl FnOnce(&Project) -> Vec<u8>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
validate_project(p)?;
|
||||
let mut bytes = if path
|
||||
let is_source = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
crate::language::format(p).into_bytes()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
||||
let mut bytes = if is_source {
|
||||
source(p)
|
||||
} else {
|
||||
serde_json::to_vec_pretty(p)?
|
||||
json_codec::encode(p, profile, depth_128)?
|
||||
};
|
||||
if !bytes.ends_with(b"\n") {
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
if is_source {
|
||||
// Check exactly the final bytes, including their newline, through the
|
||||
// public reader before creating a temp file or replacing a destination.
|
||||
let decoded = decode_with_report_limited(
|
||||
&bytes,
|
||||
crate::language::ParseLimits {
|
||||
max_source_bytes: profile.bytes,
|
||||
..crate::language::ParseLimits::UNLIMITED
|
||||
},
|
||||
)?;
|
||||
if decoded.project != *p {
|
||||
return Err(FileError::Invalid(
|
||||
"Generated source does not reproduce the complete requested project.".into(),
|
||||
));
|
||||
}
|
||||
if decoded.migration.is_some() {
|
||||
return Err(FileError::Invalid(
|
||||
"Generated source unexpectedly requires migration.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
@@ -895,8 +1007,8 @@ pub fn save_checked(
|
||||
temp.write_all(&bytes)?;
|
||||
temp.as_file().sync_all()?;
|
||||
if let Some(expected) = expected {
|
||||
match std::fs::read(path) {
|
||||
Ok(current) if current == expected => {}
|
||||
match json_codec::matches_file(path, expected) {
|
||||
Ok(true) => {}
|
||||
Ok(_) => return Err(FileError::Conflict),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
|
||||
Err(e) => return Err(FileError::Io(e)),
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Private persistence JSON boundary. Scan original bytes before Serde allocates;
|
||||
//! the scanner is a resource meter, never a second accepting JSON grammar.
|
||||
use super::FileError;
|
||||
use crate::{
|
||||
Project,
|
||||
analysis::expression_serde::{self, Scope},
|
||||
};
|
||||
use serde::{
|
||||
Deserialize,
|
||||
de::{IgnoredAny, MapAccess, Visitor},
|
||||
};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read, Write},
|
||||
};
|
||||
|
||||
pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_CONTAINERS: usize = 512;
|
||||
// This switch stays false until Windows/Linux debug/release codec + ordinary
|
||||
// lifecycle gates pass. Tests exercise the candidate through the same private
|
||||
// entry point with depth_128=true. It is not a user-controlled parser option.
|
||||
pub(super) const DEPTH_128_VALIDATED: bool = false;
|
||||
|
||||
/// Allocation cardinalities, independent of graph admission and numeric work.
|
||||
/// The default deliberately admits the existing CLI's 100000 graph objects,
|
||||
/// 250000 records and 500000 ordered references within its 64 MiB envelope.
|
||||
/// Strings have no new 1 MiB restriction: any span fitting that envelope fits.
|
||||
/// These are explicit slot/text bounds, not an assertion of process RSS.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Profile {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub container_entries: usize,
|
||||
pub string_bytes: usize,
|
||||
pub total_string_bytes: usize,
|
||||
pub expression_nodes: usize,
|
||||
}
|
||||
impl Profile {
|
||||
pub const DOCUMENT: Self = Self {
|
||||
bytes: MAX_DOCUMENT_BYTES,
|
||||
tokens: 16 * 1024 * 1024,
|
||||
entries: 8 * 1024 * 1024,
|
||||
container_entries: 500_000,
|
||||
string_bytes: MAX_DOCUMENT_BYTES,
|
||||
total_string_bytes: MAX_DOCUMENT_BYTES,
|
||||
expression_nodes: expression_serde::MAX_JSON_EXPRESSION_NODES,
|
||||
};
|
||||
pub fn with_byte_limit(maximum: usize) -> Self {
|
||||
Self {
|
||||
bytes: maximum.min(MAX_DOCUMENT_BYTES),
|
||||
..Self::DOCUMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Usage {
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub string_bytes: usize,
|
||||
pub deepest_container: usize,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Container {
|
||||
delimiter: u8,
|
||||
entries: usize,
|
||||
first: bool,
|
||||
}
|
||||
const EMPTY: Container = Container {
|
||||
delimiter: 0,
|
||||
entries: 0,
|
||||
first: true,
|
||||
};
|
||||
|
||||
fn limit(message: &str) -> FileError {
|
||||
FileError::Limit(message.into())
|
||||
}
|
||||
fn charge(used: &mut usize, amount: usize, maximum: usize, message: &str) -> Result<(), FileError> {
|
||||
if amount > maximum.saturating_sub(*used) {
|
||||
return Err(limit(message));
|
||||
}
|
||||
*used += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn preflight(bytes: &[u8], profile: Profile) -> Result<Usage, FileError> {
|
||||
if bytes.len() > profile.bytes {
|
||||
return Err(limit("JSON input byte limit exceeded."));
|
||||
}
|
||||
// Fixed storage: no Vec/Value/String and no recursive calls during preflight.
|
||||
let mut containers = [EMPTY; MAX_CONTAINERS];
|
||||
let mut depth = 0usize;
|
||||
let mut usage = Usage::default();
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let byte = bytes[cursor];
|
||||
if byte.is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
charge(
|
||||
&mut usage.tokens,
|
||||
1,
|
||||
profile.tokens,
|
||||
"JSON token limit exceeded.",
|
||||
)?;
|
||||
if !matches!(byte, b',' | b':' | b'}' | b']') && depth > 0 {
|
||||
let frame = &mut containers[depth - 1];
|
||||
if frame.first {
|
||||
charge(
|
||||
&mut frame.entries,
|
||||
1,
|
||||
profile.container_entries,
|
||||
"JSON container entry limit exceeded.",
|
||||
)?;
|
||||
charge(
|
||||
&mut usage.entries,
|
||||
1,
|
||||
profile.entries,
|
||||
"JSON aggregate entry limit exceeded.",
|
||||
)?;
|
||||
frame.first = false;
|
||||
}
|
||||
}
|
||||
match byte {
|
||||
b'{' | b'[' => {
|
||||
if depth == MAX_CONTAINERS {
|
||||
return Err(limit("JSON container depth exceeds 512."));
|
||||
}
|
||||
containers[depth] = Container {
|
||||
delimiter: byte,
|
||||
..EMPTY
|
||||
};
|
||||
depth += 1;
|
||||
usage.deepest_container = usage.deepest_container.max(depth);
|
||||
cursor += 1;
|
||||
}
|
||||
b'}' | b']' => {
|
||||
if depth == 0
|
||||
|| !matches!(
|
||||
(containers[depth - 1].delimiter, byte),
|
||||
(b'{', b'}') | (b'[', b']')
|
||||
)
|
||||
{
|
||||
return Err(FileError::Invalid(
|
||||
"Mismatched JSON container delimiter.".into(),
|
||||
));
|
||||
}
|
||||
depth -= 1;
|
||||
cursor += 1;
|
||||
}
|
||||
b',' => {
|
||||
if depth > 0 {
|
||||
containers[depth - 1].first = true;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
b':' => cursor += 1,
|
||||
b'"' => {
|
||||
cursor += 1;
|
||||
let start = cursor;
|
||||
let mut closed = false;
|
||||
while cursor < bytes.len() {
|
||||
match bytes[cursor] {
|
||||
b'"' => {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
b'\\' => {
|
||||
// Skip exactly one escaped byte, including an escaped
|
||||
// backslash. Serde validates actual escapes/UTF-8.
|
||||
cursor += 1;
|
||||
if cursor < bytes.len() {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
_ => cursor += 1,
|
||||
}
|
||||
}
|
||||
let span = cursor - start;
|
||||
if span > profile.string_bytes {
|
||||
return Err(limit("JSON string byte limit exceeded."));
|
||||
}
|
||||
charge(
|
||||
&mut usage.string_bytes,
|
||||
span,
|
||||
profile.total_string_bytes,
|
||||
"JSON aggregate string byte limit exceeded.",
|
||||
)?;
|
||||
if !closed {
|
||||
return Err(FileError::Invalid("Unterminated JSON string.".into()));
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {
|
||||
// Count a scalar token without interpreting its value. Invalid
|
||||
// tokens and invalid whitespace must still fail the Serde pass.
|
||||
cursor += 1;
|
||||
while cursor < bytes.len()
|
||||
&& !bytes[cursor].is_ascii_whitespace()
|
||||
&& !matches!(
|
||||
bytes[cursor],
|
||||
b'{' | b'[' | b'}' | b']' | b',' | b':' | b'"'
|
||||
)
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
return Err(FileError::Invalid("Unclosed JSON container.".into()));
|
||||
}
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
struct Version(Option<u64>);
|
||||
impl<'de> Deserialize<'de> for Version {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Header;
|
||||
impl<'de> Visitor<'de> for Header {
|
||||
type Value = Version;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a project header")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Version, A::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(field_identifier)]
|
||||
enum Key {
|
||||
#[serde(rename = "schema_version")]
|
||||
Schema,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
let mut version = None;
|
||||
while let Some(key) = access.next_key::<Key>()? {
|
||||
match key {
|
||||
Key::Schema => {
|
||||
if version.is_some() {
|
||||
return Err(serde::de::Error::duplicate_field("schema_version"));
|
||||
}
|
||||
version = Some(access.next_value()?);
|
||||
}
|
||||
Key::Other => {
|
||||
access.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Version(version))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Header)
|
||||
}
|
||||
}
|
||||
|
||||
/// IgnoredAny in serde_json walks arbitrary ignored containers iteratively.
|
||||
/// This probe does not materialize ignored subtrees. Serde's scratch buffer for
|
||||
/// container markers/escaped keys is bounded by the preceding raw scan. It
|
||||
/// consumes the entire original stream and rejects duplicate/escaped versions.
|
||||
pub(super) fn version(bytes: &[u8]) -> Result<Option<u64>, FileError> {
|
||||
document_stack(|| {
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
let version = Version::deserialize(&mut deserializer)?;
|
||||
deserializer.end()?;
|
||||
Ok(version.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn document_stack<R>(operation: impl FnOnce() -> R) -> R {
|
||||
// Non-Expr Project and historical schemas are finitely nested by their
|
||||
// concrete Rust types. Reserve for that outer generated Serde frame chain;
|
||||
// only recursive Expr edges use the smaller repeated boundary guard.
|
||||
stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, operation)
|
||||
}
|
||||
pub(super) fn current(
|
||||
bytes: &[u8],
|
||||
profile: Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Project, FileError> {
|
||||
preflight(bytes, profile)?;
|
||||
let _scope = Scope::decoding(profile.expression_nodes);
|
||||
document_stack(|| {
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
if depth_128 {
|
||||
deserializer.disable_recursion_limit();
|
||||
}
|
||||
let project = Project::deserialize(&mut deserializer)?;
|
||||
deserializer.end()?;
|
||||
Ok(project)
|
||||
})
|
||||
}
|
||||
pub(super) fn legacy<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, FileError> {
|
||||
// Never disable the legacy recursion guard, including its temporary Value.
|
||||
document_stack(|| Ok(serde_json::from_slice(bytes)?))
|
||||
}
|
||||
|
||||
pub(super) fn discard_value(value: serde_json::Value) {
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
serde_json::Value::Array(values) => pending.extend(values),
|
||||
serde_json::Value::Object(values) => pending.extend(values.into_values()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_bounded(bytes: &mut Vec<u8>, additional: usize, maximum: usize) -> io::Result<()> {
|
||||
let required = bytes.len() + additional;
|
||||
if required > bytes.capacity() {
|
||||
let next = bytes
|
||||
.capacity()
|
||||
.max(4096)
|
||||
.saturating_mul(2)
|
||||
.max(required)
|
||||
.min(maximum);
|
||||
bytes
|
||||
.try_reserve_exact(next - bytes.len())
|
||||
.map_err(io::Error::other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CappedWriter {
|
||||
bytes: Vec<u8>,
|
||||
maximum: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
impl CappedWriter {
|
||||
fn new(maximum: usize) -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
maximum,
|
||||
exceeded: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Write for CappedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.len() > self.maximum.saturating_sub(self.bytes.len()) {
|
||||
self.exceeded = true;
|
||||
return Err(io::Error::other("JSON output byte limit exceeded."));
|
||||
}
|
||||
// Check before reserve/extend; failed output never creates a giant Vec.
|
||||
reserve_bounded(&mut self.bytes, bytes.len(), self.maximum)?;
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact output avoids indentation multiplying the cost of legal depth128.
|
||||
/// The returned bytes have already passed the identical reader resource profile.
|
||||
pub(super) fn encode(
|
||||
project: &Project,
|
||||
profile: Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
let _scope = Scope::encoding(profile.expression_nodes);
|
||||
let mut writer = CappedWriter::new(profile.bytes);
|
||||
let result = document_stack(|| serde_json::to_writer(&mut writer, project));
|
||||
if writer.exceeded {
|
||||
return Err(limit("JSON output byte limit exceeded."));
|
||||
}
|
||||
result?;
|
||||
writer.write_all(b"\n").map_err(|error| {
|
||||
if writer.exceeded {
|
||||
limit("JSON output byte limit exceeded.")
|
||||
} else {
|
||||
FileError::Io(error)
|
||||
}
|
||||
})?;
|
||||
// Establish save/reopen symmetry before filesystem mutation, including the
|
||||
// conservative production guard until the deeper profile is validated.
|
||||
let reopened = current(&writer.bytes, profile, depth_128)?;
|
||||
if reopened != *project {
|
||||
return Err(FileError::Invalid(
|
||||
"JSON serialization did not preserve the project value.".into(),
|
||||
));
|
||||
}
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
pub(super) fn read_bounded(path: &std::path::Path, maximum: usize) -> Result<Vec<u8>, FileError> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
if file.metadata()?.len() > maximum as u64 {
|
||||
return Err(limit("Input file byte limit exceeded."));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let count = file.read(&mut chunk)?;
|
||||
if count == 0 {
|
||||
return Ok(bytes);
|
||||
}
|
||||
if count > maximum.saturating_sub(bytes.len()) {
|
||||
return Err(limit("Input file byte limit exceeded."));
|
||||
}
|
||||
reserve_bounded(&mut bytes, count, maximum)?;
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matches_file(path: &std::path::Path, expected: &[u8]) -> io::Result<bool> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
if file.metadata()?.len() != expected.len() as u64 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut compared = 0usize;
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let count = file.read(&mut chunk)?;
|
||||
if count == 0 {
|
||||
return Ok(compared == expected.len());
|
||||
}
|
||||
if expected.get(compared..compared + count) != Some(&chunk[..count]) {
|
||||
return Ok(false);
|
||||
}
|
||||
compared += count;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,612 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, ElementBody, Id, OaBody,
|
||||
analysis::{
|
||||
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, Expr, Parameter,
|
||||
ParameterId, ScalarType, UnaryOp,
|
||||
},
|
||||
language::ParseLimits,
|
||||
persistence,
|
||||
};
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const IDENTITY: &str = "00000000-0000-0000-0000-000000000001";
|
||||
const PARAMETER: &str = "00000000-0000-0000-0000-000000000002";
|
||||
const MAIN: &str = "00000000-0000-0000-0000-000000000003";
|
||||
fn id(value: &str) -> Id {
|
||||
Id::parse_str(value).unwrap()
|
||||
}
|
||||
fn boolean_type() -> AnalysisType {
|
||||
AnalysisType::Scalar(ScalarType::Boolean)
|
||||
}
|
||||
fn definition(id: Id, alias: &str, parameters: Vec<Parameter>, body: Expr) -> Definition {
|
||||
Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters,
|
||||
result: boolean_type(),
|
||||
body: Binding::Expression(body),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn project() -> Project {
|
||||
let mut project = Project::empty();
|
||||
project.id = Id::from_u128(1000);
|
||||
project.definitions.insert(
|
||||
id(IDENTITY),
|
||||
definition(
|
||||
id(IDENTITY),
|
||||
"identity",
|
||||
vec![Parameter {
|
||||
id: ParameterId(id(PARAMETER)),
|
||||
name: "value".into(),
|
||||
value_type: boolean_type(),
|
||||
}],
|
||||
Expr::Parameter(ParameterId(id(PARAMETER))),
|
||||
),
|
||||
);
|
||||
project.definitions.insert(
|
||||
id(MAIN),
|
||||
definition(id(MAIN), "main", vec![], Expr::Boolean(true)),
|
||||
);
|
||||
project
|
||||
}
|
||||
fn wire(expression: &str) -> Vec<u8> {
|
||||
let shallow = serde_json::to_string(&project()).unwrap();
|
||||
assert_eq!(shallow.matches(r#"{"Boolean":true}"#).count(), 1);
|
||||
shallow
|
||||
.replace(r#"{"Boolean":true}"#, expression)
|
||||
.into_bytes()
|
||||
}
|
||||
fn chain(depth: usize, call: bool) -> String {
|
||||
assert!(depth > 0);
|
||||
let prefix = if call {
|
||||
format!(r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":"#)
|
||||
} else {
|
||||
r#"{"Unary":{"op":"Not","operand":"#.into()
|
||||
};
|
||||
let suffix = if call { "}}}" } else { "}}" };
|
||||
format!(
|
||||
"{}{}{}",
|
||||
prefix.repeat(depth - 1),
|
||||
r#"{"Boolean":true}"#,
|
||||
suffix.repeat(depth - 1)
|
||||
)
|
||||
}
|
||||
fn candidate(bytes: &[u8], profile: Profile) -> Result<Project, FileError> {
|
||||
persistence::decode_with_report_profile(bytes, ParseLimits::UNLIMITED, profile, true)
|
||||
.map(|decoded| decoded.project)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_preflight_has_exact_lowerable_boundaries() {
|
||||
let bytes = br#"{"key":["a\\\"[{}]",0,true],"other":"\u0061"}"#;
|
||||
let usage = preflight(bytes, Profile::DOCUMENT).unwrap();
|
||||
let exact = Profile {
|
||||
bytes: bytes.len(),
|
||||
tokens: usage.tokens,
|
||||
entries: usage.entries,
|
||||
container_entries: 3,
|
||||
string_bytes: 9,
|
||||
total_string_bytes: usage.string_bytes,
|
||||
..Profile::DOCUMENT
|
||||
};
|
||||
preflight(bytes, exact).unwrap();
|
||||
for lower in [
|
||||
Profile {
|
||||
bytes: exact.bytes - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
tokens: exact.tokens - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
entries: exact.entries - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
container_entries: 2,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
total_string_bytes: exact.total_string_bytes - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
string_bytes: exact.string_bytes - 1,
|
||||
..exact
|
||||
},
|
||||
] {
|
||||
assert!(matches!(preflight(bytes, lower), Err(FileError::Limit(_))));
|
||||
}
|
||||
let deepest = format!("{}0{}", "[".repeat(512), "]".repeat(512));
|
||||
assert_eq!(
|
||||
preflight(deepest.as_bytes(), Profile::DOCUMENT)
|
||||
.unwrap()
|
||||
.deepest_container,
|
||||
512
|
||||
);
|
||||
assert!(matches!(
|
||||
preflight(format!("[{deepest}]").as_bytes(), Profile::DOCUMENT),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
// A huge ignored scalar array exhausts preflight before a header is decoded.
|
||||
assert!(matches!(
|
||||
preflight(
|
||||
br#"{"schema_version":8,"junk":[0,0,0,0]}"#,
|
||||
Profile {
|
||||
container_entries: 3,
|
||||
..Profile::DOCUMENT
|
||||
}
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_is_not_a_parallel_json_grammar() {
|
||||
for bytes in [
|
||||
br#"{"schema_version":8}{}"#.as_slice(),
|
||||
br#"{"schema_version":8,}"#,
|
||||
br#"{"schema_version":8 "junk":0}"#,
|
||||
br#"{"schema_version":8,"schema_version":8}"#,
|
||||
br#"{"schema_version":8,"\u0073chema_version":8}"#,
|
||||
br#"{"schema_version":8,"junk":"\q"}"#,
|
||||
br#"{"schema_version":8,"junk":"unfinished}"#,
|
||||
br#"{"schema_version":8,"junk":[}"#,
|
||||
] {
|
||||
assert!(candidate(bytes, Profile::DOCUMENT).is_err(), "{bytes:?}");
|
||||
}
|
||||
let mut invalid_utf8 = wire(r#"{"Boolean":true}"#);
|
||||
let offset = invalid_utf8
|
||||
.windows(7)
|
||||
.position(|window| window == b"mission")
|
||||
.unwrap();
|
||||
invalid_utf8[offset] = 0xff;
|
||||
assert!(candidate(&invalid_utf8, Profile::DOCUMENT).is_err());
|
||||
let escaped = String::from_utf8(wire(r#"{"Boolean":true}"#))
|
||||
.unwrap()
|
||||
.replace(r#""schema_version""#, r#""\u0073chema_version""#);
|
||||
assert_eq!(
|
||||
candidate(escaped.as_bytes(), Profile::DOCUMENT).unwrap(),
|
||||
project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_serde_keeps_each_existing_expression_variant() {
|
||||
let n = r#"{"Number":{"numerator":"1","denominator":"1"}}"#;
|
||||
let b = r#"{"Boolean":true}"#;
|
||||
let expressions = [
|
||||
b.to_string(),
|
||||
n.to_string(),
|
||||
format!(
|
||||
r#"{{"Quantity":{{"magnitude":{{"numerator":"1","denominator":"1"}},"unit":{{"Local":"{IDENTITY}"}}}}}}"#
|
||||
),
|
||||
format!(r#"{{"Property":"{IDENTITY}"}}"#),
|
||||
format!(r#"{{"Parameter":"{PARAMETER}"}}"#),
|
||||
format!(r#"{{"List":[{n},{n}]}}"#),
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{b}}}}}"#),
|
||||
format!(r#"{{"Binary":{{"op":"Add","left":{n},"right":{n}}}}}"#),
|
||||
format!(r#"{{"If":{{"condition":{b},"when_true":{n},"when_false":{n}}}}}"#),
|
||||
format!(
|
||||
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{b}}}}}}}"#
|
||||
),
|
||||
format!(r#"{{"Sum":{{"values":{{"List":[{n}]}},"initial":{n}}}}}"#),
|
||||
format!(r#"{{"Power":{{"base":{n},"exponent":2}}}}"#),
|
||||
format!(r#"{{"AsKind":{{"kind":{{"Local":"{IDENTITY}"}},"value":{n}}}}}"#),
|
||||
];
|
||||
for original in expressions {
|
||||
let value: Expr = serde_json::from_str(&original).unwrap();
|
||||
let copy = value.clone();
|
||||
assert!(value == copy);
|
||||
assert_eq!(serde_json::to_string(&value).unwrap(), original);
|
||||
assert_eq!(format!("{value:?}"), format!("{copy:?}"));
|
||||
}
|
||||
let list = Expr::List(vec![Expr::Boolean(true), Expr::Boolean(false)]);
|
||||
assert_eq!(format!("{list:?}"), "List([Boolean(true), Boolean(false)])");
|
||||
assert!(list != Expr::List(vec![Expr::Boolean(false), Expr::Boolean(true)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_profile_does_not_contract_declared_cli_collection_counts() {
|
||||
// Exercise original-byte typed construction at the existing graph ceiling.
|
||||
// This gate checks codec/count compatibility, not 100000-object UI speed.
|
||||
let mut model = Project::empty();
|
||||
model.id = Id::from_u128(1_000_000);
|
||||
for value in 1..=100_000 {
|
||||
let id = Id::from_u128(value);
|
||||
model.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("activity_{value}"),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::Oa(OaBody::Activity),
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
let decoded = current(&bytes, Profile::DOCUMENT, true).unwrap();
|
||||
assert_eq!(decoded.elements.len(), 100_000);
|
||||
let cli = ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
};
|
||||
cli.check_model(&decoded).unwrap();
|
||||
let references = format!(
|
||||
"[{}\"{IDENTITY}\"]",
|
||||
format!("\"{IDENTITY}\",").repeat(499_999)
|
||||
);
|
||||
preflight(references.as_bytes(), Profile::DOCUMENT).unwrap();
|
||||
assert!(matches!(
|
||||
preflight(
|
||||
format!("[\"{IDENTITY}\",{}]", &references[1..references.len() - 1]).as_bytes(),
|
||||
Profile::DOCUMENT
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
// Metadata over the initially proposed 1 MiB limit remains admissible.
|
||||
let mut text = Project::empty();
|
||||
text.description = "x".repeat(1024 * 1024 + 1);
|
||||
let bytes = encode(&text, Profile::DOCUMENT, true).unwrap();
|
||||
assert_eq!(current(&bytes, Profile::DOCUMENT, true).unwrap(), text);
|
||||
eprintln!(
|
||||
"JSON profile: graph bytes={}, ordered-reference bytes={}, max container entries={}",
|
||||
serde_json::to_string(&model).unwrap().len(),
|
||||
references.len(),
|
||||
Profile::DOCUMENT.container_entries
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_save_always_reopens_with_its_reader_profile() {
|
||||
let deep = candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.json");
|
||||
std::fs::write(&path, b"previous").unwrap();
|
||||
let saved = persistence::save_checked(&path, &deep, Some(b"previous"));
|
||||
if DEPTH_128_VALIDATED {
|
||||
let bytes = saved.unwrap();
|
||||
assert_eq!(persistence::decode(&bytes).unwrap(), deep);
|
||||
} else {
|
||||
assert!(saved.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous");
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_phase() {
|
||||
for call in [false, true] {
|
||||
let bytes = wire(&chain(128, call));
|
||||
let usage = preflight(&bytes, Profile::DOCUMENT).unwrap();
|
||||
let model = candidate(&bytes, Profile::DOCUMENT).unwrap();
|
||||
let copy = model.clone();
|
||||
assert!(model == copy);
|
||||
let debug = format!("{model:?}");
|
||||
assert!(!debug.is_empty());
|
||||
let output = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
assert!(candidate(&output, Profile::DOCUMENT).unwrap() == model);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("deep.json");
|
||||
let saved = persistence::save_checked_profile(&path, &model, None, Profile::DOCUMENT, true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
candidate(
|
||||
&read_bounded(&path, MAX_DOCUMENT_BYTES).unwrap(),
|
||||
Profile::DOCUMENT
|
||||
)
|
||||
.unwrap()
|
||||
== model
|
||||
);
|
||||
assert_eq!(saved, output);
|
||||
eprintln!(
|
||||
"accepted call={call} depth128 input={} output={} tokens={} entries={} raw strings={} containers={} debug={}",
|
||||
bytes.len(),
|
||||
output.len(),
|
||||
usage.tokens,
|
||||
usage.entries,
|
||||
usage.string_bytes,
|
||||
usage.deepest_container,
|
||||
debug.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
fn refused_phase() {
|
||||
for call in [false, true] {
|
||||
let error = candidate(&wire(&chain(129, call)), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(error.to_string().contains("logical depth"), "{error}");
|
||||
}
|
||||
let wide = format!(
|
||||
r#"{{"List":[{}null]}}"#,
|
||||
r#"{"Boolean":true},"#.repeat(1024)
|
||||
);
|
||||
let error = candidate(&wire(&wide), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(error.to_string().contains("1024"), "{error}");
|
||||
let bytes = wire(&chain(128, true));
|
||||
let exact_model = candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
expression_nodes: 129,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let error = candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
expression_nodes: 128,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
// Identity's Parameter plus main's 128-node expression exceed this total.
|
||||
assert!(error.to_string().contains("allocation count"), "{error}");
|
||||
let scan = preflight(&bytes, Profile::DOCUMENT).unwrap();
|
||||
assert!(matches!(
|
||||
candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
tokens: scan.tokens - 1,
|
||||
..Profile::DOCUMENT
|
||||
}
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
document_stack(|| {
|
||||
use crate::analysis::{AdmissionLimits, admit};
|
||||
let events = admit(&exact_model, AdmissionLimits::REFERENCE)
|
||||
.unwrap()
|
||||
.admission_usage()
|
||||
.static_events;
|
||||
admit(
|
||||
&exact_model,
|
||||
AdmissionLimits {
|
||||
max_static_events: events,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let error = match admit(
|
||||
&exact_model,
|
||||
AdmissionLimits {
|
||||
max_static_events: events - 1,
|
||||
},
|
||||
) {
|
||||
Ok(_) => panic!("Static work below the measured exact count must refuse."),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code, "analysis_limit");
|
||||
});
|
||||
let mut tree = r#"{"Boolean":true}"#.to_owned();
|
||||
for _ in 0..15 {
|
||||
tree = format!(r#"{{"Binary":{{"op":"And","left":{tree},"right":{tree}}}}}"#);
|
||||
}
|
||||
// 65535 balanced nodes plus one unary node is the stored-tree boundary.
|
||||
tree = format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#);
|
||||
let at_tree_limit = wire(&tree);
|
||||
current(&at_tree_limit, Profile::DOCUMENT, true).unwrap();
|
||||
let beyond = wire(&format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#));
|
||||
assert!(
|
||||
current(&beyond, Profile::DOCUMENT, true)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("65536")
|
||||
);
|
||||
// No downstream work after refusal poisons the next ordinary operation.
|
||||
candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
|
||||
}
|
||||
fn malformed_phase() {
|
||||
let deep = chain(127, false);
|
||||
for (label, expression) in [
|
||||
(
|
||||
"duplicate operand",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"operand":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"escaped duplicate",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"\u006fperand":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"unknown field",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"surprise":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"duplicate enum",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep}}},"Boolean":true}}"#),
|
||||
),
|
||||
(
|
||||
"duplicate parameter",
|
||||
format!(
|
||||
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{deep},"\u0030{}":null}}}}}}"#,
|
||||
&PARAMETER[1..]
|
||||
),
|
||||
),
|
||||
] {
|
||||
let error = candidate(&wire(&expression), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(
|
||||
!error.to_string().contains("logical depth"),
|
||||
"{label}: {error}"
|
||||
);
|
||||
eprintln!("{label}: {error}");
|
||||
}
|
||||
let complete = String::from_utf8(wire(&chain(128, true))).unwrap();
|
||||
let duplicate_late = format!(
|
||||
r#"{},"name":"late duplicate"}}"#,
|
||||
&complete[..complete.len() - 1]
|
||||
);
|
||||
assert!(candidate(duplicate_late.as_bytes(), Profile::DOCUMENT).is_err());
|
||||
assert!(candidate(format!("{complete} null").as_bytes(), Profile::DOCUMENT).is_err());
|
||||
let invalid_surrogate = complete.replace(r#""description":"""#, r#""description":"\uD800""#);
|
||||
assert!(candidate(invalid_surrogate.as_bytes(), Profile::DOCUMENT).is_err());
|
||||
// Fully rejected raw depth also tears down without a recursive JSON Value.
|
||||
let excessive = format!("{}0{}", "[".repeat(100_000), "]".repeat(100_000));
|
||||
assert!(matches!(
|
||||
preflight(excessive.as_bytes(), Profile::DOCUMENT),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
}
|
||||
fn ownership_phase() {
|
||||
let mut value = Expr::Boolean(true);
|
||||
for _ in 1..100_000 {
|
||||
value = Expr::Unary {
|
||||
op: UnaryOp::Not,
|
||||
operand: Box::new(value),
|
||||
};
|
||||
}
|
||||
let mut copy = value.clone();
|
||||
assert!(value == copy);
|
||||
let mut cursor = &mut copy;
|
||||
loop {
|
||||
match cursor {
|
||||
Expr::Unary { operand, .. } => cursor = operand,
|
||||
Expr::Boolean(leaf) => {
|
||||
*leaf = false;
|
||||
break;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert!(value != copy);
|
||||
// A streaming formatter avoids retaining a 100000-node Debug string.
|
||||
struct Counter(usize);
|
||||
impl fmt::Write for Counter {
|
||||
fn write_str(&mut self, value: &str) -> fmt::Result {
|
||||
self.0 += value.len();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut debug = Counter(0);
|
||||
write!(&mut debug, "{value:?}").unwrap();
|
||||
assert!(debug.0 > 100_000);
|
||||
assert!(serde_json::to_writer(io::sink(), &value).is_err());
|
||||
drop(value);
|
||||
drop(copy);
|
||||
eprintln!(
|
||||
"iterative manual100000 ownership/debug/drop completed; debug bytes={}",
|
||||
debug.0
|
||||
);
|
||||
}
|
||||
fn atomic_phase() {
|
||||
let model = project();
|
||||
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
let exact = Profile {
|
||||
bytes: bytes.len(),
|
||||
..Profile::DOCUMENT
|
||||
};
|
||||
assert_eq!(encode(&model, exact, true).unwrap(), bytes);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("preserved.json");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let error = persistence::save_checked_profile(
|
||||
&path,
|
||||
&model,
|
||||
Some(b"original"),
|
||||
Profile {
|
||||
bytes: bytes.len() - 1,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FileError::Limit(_)));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
let error = persistence::save_checked_profile(
|
||||
&path,
|
||||
&model,
|
||||
Some(b"different"),
|
||||
Profile::DOCUMENT,
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FileError::Conflict));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
assert!(read_bounded(&path, 7).is_err());
|
||||
assert_eq!(read_bounded(&path, 8).unwrap(), b"original");
|
||||
let mut writer = CappedWriter::new(3);
|
||||
writer.write_all(b"abc").unwrap();
|
||||
let capacity = writer.bytes.capacity();
|
||||
assert!(writer.write_all(b"d").is_err());
|
||||
assert_eq!(writer.bytes, b"abc");
|
||||
assert_eq!(writer.bytes.capacity(), capacity);
|
||||
}
|
||||
|
||||
/// Root runs this exact test in each approved target/profile. Each phase runs in
|
||||
/// another process so a stack overflow is an explicit failed phase, and inside
|
||||
/// a 128 KiB thread (smaller than the ordinary test stack). No RUST_MIN_STACK
|
||||
/// override, ignored test, release-only path or increased test stack.
|
||||
#[test]
|
||||
fn codec_lifecycle_subprocess() {
|
||||
const PHASE: &str = "ARCADIA_CODEC_LIFECYCLE_PHASE";
|
||||
if let Ok(phase) = std::env::var(PHASE) {
|
||||
eprintln!("codec child phase={phase}, configured thread stack=131072");
|
||||
std::thread::Builder::new()
|
||||
.name(format!("codec-{phase}"))
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(move || match phase.as_str() {
|
||||
"accepted" => accepted_phase(),
|
||||
"refused" => refused_phase(),
|
||||
"malformed" => malformed_phase(),
|
||||
"ownership" => ownership_phase(),
|
||||
"atomic" => atomic_phase(),
|
||||
_ => panic!("Unknown codec phase"),
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
for phase in ["accepted", "refused", "malformed", "ownership", "atomic"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"persistence::json_codec::tests::codec_lifecycle_subprocess",
|
||||
"--test-threads=1",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(PHASE, phase)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(120) {
|
||||
child.kill().unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
panic!(
|
||||
"codec {phase} timed out: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
eprintln!(
|
||||
"codec {phase}: status={} elapsed={:?}\n{}{}",
|
||||
output.status,
|
||||
start.elapsed(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"codec phase {phase} aborted or failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Containment while the complete source8 adapter remains unavailable.
|
||||
use super::*;
|
||||
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
|
||||
use crate::analysis_numeric::Dimension;
|
||||
use std::fs;
|
||||
|
||||
const ORDINARY_SOURCE7: &str = concat!(
|
||||
"arcadia 7;\n",
|
||||
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
|
||||
" description \"Retain café and exact identity\";\n",
|
||||
"}",
|
||||
);
|
||||
|
||||
fn ordinary() -> Project {
|
||||
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert!(decoded.migration.is_some());
|
||||
decoded.project
|
||||
}
|
||||
|
||||
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
|
||||
let mut names = fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
// A missing parent makes a mistakenly early NamedTempFile creation observable
|
||||
// as Io, even when the temp would otherwise be removed again on error.
|
||||
fn rejected_output(
|
||||
project: &Project,
|
||||
output: &[u8],
|
||||
profile: json_codec::Profile,
|
||||
check: impl Fn(&FileError),
|
||||
) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let existing = root.path().join("existing.arcadia");
|
||||
let sentinel = b"external bytes must survive\r\n";
|
||||
fs::write(&existing, sentinel).unwrap();
|
||||
for path in [
|
||||
existing.clone(),
|
||||
root.path().join("new.arcadia"),
|
||||
root.path().join("missing-parent/new.arcadia"),
|
||||
] {
|
||||
let before = directory_entries(root.path());
|
||||
let expected = (path == existing).then_some(sentinel.as_slice());
|
||||
let error =
|
||||
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
|
||||
output.to_vec()
|
||||
})
|
||||
.unwrap_err();
|
||||
check(&error);
|
||||
assert_eq!(directory_entries(root.path()), before);
|
||||
assert_eq!(fs::read(&existing).unwrap(), sentinel);
|
||||
if path != existing {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
b"arcadia",
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
|
||||
let mut project = ordinary();
|
||||
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
|
||||
project.definitions.insert(
|
||||
id,
|
||||
Definition {
|
||||
id,
|
||||
alias: "duration".into(),
|
||||
name: "Duration".into(),
|
||||
description: "Unused definitions are still authored model data.".into(),
|
||||
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: QuantityAlgebra::Linear,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_project(&project).unwrap();
|
||||
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert_eq!(decoded.id, project.id);
|
||||
assert_eq!(decoded.name, project.name);
|
||||
assert_eq!(decoded.elements, project.elements);
|
||||
assert!(decoded.definitions.is_empty());
|
||||
assert_ne!(decoded, project);
|
||||
rejected_output(
|
||||
&project,
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source does not reproduce the complete requested project."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
|
||||
let project = ordinary();
|
||||
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
|
||||
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
|
||||
assert!(matches!(error, FileError::Limit(_)), "{error}");
|
||||
});
|
||||
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
|
||||
terminated.push(b'\n');
|
||||
let profile = json_codec::Profile::with_byte_limit(terminated.len());
|
||||
// Existing final newline is not added twice: decoding now reaches the
|
||||
// independent migration refusal, rather than exceeding this exact limit.
|
||||
rejected_output(&project, &terminated, profile, |error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
use crate::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Every live reference in a typed element, including structural ownership.
|
||||
/// Architectural/project references, including analysis subjects/properties.
|
||||
/// Layer-neutral definition references and scoped parameters have separate domains.
|
||||
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||
if e.kind().is_analysis() {
|
||||
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
|
||||
references.extend(e.parent);
|
||||
return references;
|
||||
}
|
||||
let mut copy = e.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||
@@ -14,6 +20,8 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
/// Architectural mutable slots only. Analysis map keys require the fallible
|
||||
/// whole-project remap; use analysis::references for its typed domains.
|
||||
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
if let Some(parent) = &mut e.parent {
|
||||
visit(parent);
|
||||
@@ -203,7 +211,10 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
crate::analysis::references::check_remap(p, old, new)
|
||||
.map_err(|error| ModelError(error.to_string()))?;
|
||||
crate::analysis::references::remap_project(p, old, new);
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -265,6 +276,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -405,6 +417,9 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
.iter()
|
||||
.map(|id| (*id, p.named(*id).to_string()))
|
||||
.collect();
|
||||
// Analysis dependencies remain live: deleting their target rejects the
|
||||
// transaction unless that transaction also explicitly repairs/removes them.
|
||||
p.definitions.retain(|id, _| !removed.contains(id));
|
||||
p.elements.retain(|id, _| !removed.contains(id));
|
||||
p.relations.retain(|id, _| !removed.contains(id));
|
||||
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||
|
||||
@@ -604,6 +604,9 @@ fn catalog_facets(
|
||||
include_linked: bool,
|
||||
) -> Result<Vec<FacetDrift>, String> {
|
||||
let (oa, sa) = (trace.oa, trace.sa);
|
||||
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
|
||||
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
|
||||
}
|
||||
let mut selectors: BTreeSet<_> = scalar_selectors()
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
|
||||
@@ -19,6 +19,21 @@ pub enum Command {
|
||||
},
|
||||
CreateElement(Element),
|
||||
EditElement(Id, Element),
|
||||
CreateDefinition(crate::analysis::Definition),
|
||||
EditDefinition(Id, crate::analysis::Definition),
|
||||
SetAnalysisBase {
|
||||
property: Id,
|
||||
binding: crate::analysis::Binding,
|
||||
},
|
||||
SetAnalysisOverride {
|
||||
case: Id,
|
||||
property: Id,
|
||||
binding: crate::analysis::Binding,
|
||||
},
|
||||
ClearAnalysisOverride {
|
||||
case: Id,
|
||||
property: Id,
|
||||
},
|
||||
CreateRelation(Relation),
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
@@ -453,6 +468,47 @@ fn apply_command(
|
||||
}
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateDefinition(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.definitions.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditDefinition(old, value) => {
|
||||
if !p.definitions.contains_key(old) { return Err(missing()); }
|
||||
let mut definition = value.clone();
|
||||
definition.id = *old;
|
||||
p.definitions.insert(*old, definition);
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetAnalysisBase { property, binding } => {
|
||||
let element = p.elements.get_mut(property).ok_or_else(missing)?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
|
||||
};
|
||||
*base = binding.clone();
|
||||
Some(*property)
|
||||
}
|
||||
Command::SetAnalysisOverride { case, property, binding } => {
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.insert(*property, binding.clone());
|
||||
Some(*case)
|
||||
}
|
||||
Command::ClearAnalysisOverride { case, property } => {
|
||||
let target = p.elements.get(property).ok_or_else(missing)?;
|
||||
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
|
||||
let layer = target.layer();
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.remove(property);
|
||||
Some(*case)
|
||||
}
|
||||
Command::CreateRelation(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.relations.insert(value.id, value.clone());
|
||||
@@ -835,6 +891,10 @@ fn edit_header(
|
||||
assign(&mut p.name, &mut p.description, &mut p.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.definitions.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.elements.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
@@ -878,8 +938,13 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::analysis::references::check_remap(p, old, new)
|
||||
.map_err(|error| ModelError(error.to_string()))?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.definitions.remove(&old) {
|
||||
v.id = new;
|
||||
p.definitions.insert(new, v);
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
v.id = new;
|
||||
p.elements.insert(new, v);
|
||||
@@ -904,7 +969,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
mod source_migration;
|
||||
#[path = "source_project/source_structure.rs"]
|
||||
mod source_structure;
|
||||
pub(crate) mod traversal;
|
||||
|
||||
/// A portable slash-separated path, checked at every bundle boundary.
|
||||
pub type LogicalPath = String;
|
||||
@@ -304,71 +305,7 @@ fn compile_once(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<(CompiledSourceProject, Edits), Vec<SourceDiagnostic>> {
|
||||
let fail = |code, message| {
|
||||
vec![problem(
|
||||
bundle,
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
code,
|
||||
message,
|
||||
)]
|
||||
};
|
||||
if bundle.entry.contains('/') {
|
||||
return Err(fail(
|
||||
"invalid_path",
|
||||
"The entry file must be at the source project root.".into(),
|
||||
));
|
||||
}
|
||||
if bundle.files.len() > limits.max_files {
|
||||
return Err(fail(
|
||||
"project_limit",
|
||||
format!("Source project exceeds {} files.", limits.max_files),
|
||||
));
|
||||
}
|
||||
let bytes = bundle
|
||||
.files
|
||||
.values()
|
||||
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
|
||||
if bytes > limits.model.max_source_bytes {
|
||||
return Err(fail(
|
||||
"source_limit",
|
||||
format!(
|
||||
"Combined source exceeds {} bytes.",
|
||||
limits.model.max_source_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut folded = BTreeMap::new();
|
||||
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
|
||||
if resolve_include("", path).as_ref() != Ok(path) {
|
||||
return Err(fail(
|
||||
"invalid_path",
|
||||
format!("Non-canonical source path '{path}'."),
|
||||
));
|
||||
}
|
||||
if let Some(previous) = folded.insert(path.to_lowercase(), path)
|
||||
&& previous != path
|
||||
{
|
||||
return Err(fail(
|
||||
"duplicate_path",
|
||||
format!("Case-colliding source paths '{previous}' and '{path}'."),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !bundle.files.contains_key(&bundle.entry) {
|
||||
return Err(fail(
|
||||
"missing_include",
|
||||
"The entry file is missing from the source bundle.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
enum Visit {
|
||||
Enter(LogicalPath, usize, Option<SourceLocation>),
|
||||
Leave,
|
||||
}
|
||||
let mut stack = vec![Visit::Enter(bundle.entry.clone(), 0, None)];
|
||||
let mut active: Vec<LogicalPath> = Vec::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut traversal = traversal::Traversal::new(bundle, limits)?;
|
||||
let mut declarations = Vec::new();
|
||||
let mut declaration_paths = Vec::new();
|
||||
let mut aliases = BTreeMap::new();
|
||||
@@ -381,51 +318,8 @@ fn compile_once(
|
||||
let mut includes = Vec::new();
|
||||
let mut insertions: Edits = BTreeMap::new();
|
||||
let mut graph_count = 0usize;
|
||||
while let Some(visit) = stack.pop() {
|
||||
let Visit::Enter(path, depth, origin) = visit else {
|
||||
active.pop();
|
||||
continue;
|
||||
};
|
||||
let at = origin.unwrap_or(SourceLocation {
|
||||
path: path.clone(),
|
||||
span: Span::default(),
|
||||
});
|
||||
if let Some(start) = active.iter().position(|file| file == &path) {
|
||||
let chain = active[start..]
|
||||
.iter()
|
||||
.chain(std::iter::once(&path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" -> ");
|
||||
return Err(vec![problem(
|
||||
bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"include_cycle",
|
||||
format!("Include cycle: {chain}."),
|
||||
)]);
|
||||
}
|
||||
if depth > limits.max_include_depth {
|
||||
return Err(vec![problem(
|
||||
bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"project_limit",
|
||||
format!("Include depth exceeds {}.", limits.max_include_depth),
|
||||
)]);
|
||||
}
|
||||
if !visited.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = bundle.files.get(&path) else {
|
||||
return Err(vec![problem(
|
||||
bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"missing_include",
|
||||
format!("Included file '{path}' is missing."),
|
||||
)]);
|
||||
};
|
||||
while let Some(path) = traversal.next()? {
|
||||
let source = &bundle.files[&path];
|
||||
let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err(
|
||||
|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
@@ -513,46 +407,12 @@ fn compile_once(
|
||||
declarations.push(declaration);
|
||||
declaration_paths.push(path.clone());
|
||||
}
|
||||
let mut children = Vec::new();
|
||||
for (raw, span) in unit.includes {
|
||||
let target = resolve_include(&path, &raw)
|
||||
.map_err(|message| vec![problem(bundle, &path, span, "invalid_path", message)])?;
|
||||
let location = SourceLocation {
|
||||
path: path.clone(),
|
||||
span,
|
||||
};
|
||||
includes.push(IncludeLocation {
|
||||
location: location.clone(),
|
||||
target: target.clone(),
|
||||
});
|
||||
if includes.len() > limits.max_include_edges {
|
||||
return Err(vec![problem(
|
||||
bundle,
|
||||
&path,
|
||||
span,
|
||||
"project_limit",
|
||||
format!("Include graph exceeds {} edges.", limits.max_include_edges),
|
||||
)]);
|
||||
}
|
||||
children.push(Visit::Enter(
|
||||
target,
|
||||
depth.saturating_add(1),
|
||||
Some(location),
|
||||
));
|
||||
includes.push(traversal.include(&path, &raw, span)?);
|
||||
}
|
||||
active.push(path);
|
||||
stack.push(Visit::Leave);
|
||||
stack.extend(children.into_iter().rev());
|
||||
}
|
||||
if let Some(path) = bundle.files.keys().find(|path| !visited.contains(*path)) {
|
||||
return Err(vec![problem(
|
||||
bundle,
|
||||
path,
|
||||
Span::default(),
|
||||
"unreachable_file",
|
||||
"Supplied source file is not reachable from the entry file.",
|
||||
)]);
|
||||
traversal.finish_file(path);
|
||||
}
|
||||
traversal.finish()?;
|
||||
let header = project_header.expect("validated entry header");
|
||||
let index = SourceIndex {
|
||||
declarations: declaration_locations,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Shared include traversal policy for ordinary compilation and private syntax.
|
||||
use super::*;
|
||||
|
||||
enum Visit {
|
||||
Enter(LogicalPath, usize, Option<SourceLocation>),
|
||||
Leave,
|
||||
}
|
||||
|
||||
pub(crate) struct Traversal<'a> {
|
||||
bundle: &'a SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
stack: Vec<Visit>,
|
||||
active: Vec<LogicalPath>,
|
||||
visited: BTreeSet<LogicalPath>,
|
||||
children: Vec<Visit>,
|
||||
depth: usize,
|
||||
edges: usize,
|
||||
}
|
||||
|
||||
impl<'a> Traversal<'a> {
|
||||
pub(crate) fn new(
|
||||
bundle: &'a SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<Self, Vec<SourceDiagnostic>> {
|
||||
let fail = |code, message| {
|
||||
vec![problem(
|
||||
bundle,
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
code,
|
||||
message,
|
||||
)]
|
||||
};
|
||||
if bundle.entry.contains('/') {
|
||||
return Err(fail(
|
||||
"invalid_path",
|
||||
"The entry file must be at the source project root.".into(),
|
||||
));
|
||||
}
|
||||
if bundle.files.len() > limits.max_files {
|
||||
return Err(fail(
|
||||
"project_limit",
|
||||
format!("Source project exceeds {} files.", limits.max_files),
|
||||
));
|
||||
}
|
||||
let bytes = bundle
|
||||
.files
|
||||
.values()
|
||||
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
|
||||
if bytes > limits.model.max_source_bytes {
|
||||
return Err(fail(
|
||||
"source_limit",
|
||||
format!(
|
||||
"Combined source exceeds {} bytes.",
|
||||
limits.model.max_source_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut folded = BTreeMap::new();
|
||||
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
|
||||
if resolve_include("", path).as_ref() != Ok(path) {
|
||||
return Err(fail(
|
||||
"invalid_path",
|
||||
format!("Non-canonical source path '{path}'."),
|
||||
));
|
||||
}
|
||||
if let Some(previous) = folded.insert(path.to_lowercase(), path)
|
||||
&& previous != path
|
||||
{
|
||||
return Err(fail(
|
||||
"duplicate_path",
|
||||
format!("Case-colliding source paths '{previous}' and '{path}'."),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !bundle.files.contains_key(&bundle.entry) {
|
||||
return Err(fail(
|
||||
"missing_include",
|
||||
"The entry file is missing from the source bundle.".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
bundle,
|
||||
limits,
|
||||
stack: vec![Visit::Enter(bundle.entry.clone(), 0, None)],
|
||||
active: Vec::new(),
|
||||
visited: BTreeSet::new(),
|
||||
children: Vec::new(),
|
||||
depth: 0,
|
||||
edges: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next(&mut self) -> Result<Option<LogicalPath>, Vec<SourceDiagnostic>> {
|
||||
while let Some(visit) = self.stack.pop() {
|
||||
let Visit::Enter(path, depth, origin) = visit else {
|
||||
self.active.pop();
|
||||
continue;
|
||||
};
|
||||
let at = origin.unwrap_or(SourceLocation {
|
||||
path: path.clone(),
|
||||
span: Span::default(),
|
||||
});
|
||||
if let Some(start) = self.active.iter().position(|file| file == &path) {
|
||||
let chain = self.active[start..]
|
||||
.iter()
|
||||
.chain(std::iter::once(&path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" -> ");
|
||||
return Err(vec![problem(
|
||||
self.bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"include_cycle",
|
||||
format!("Include cycle: {chain}."),
|
||||
)]);
|
||||
}
|
||||
if depth > self.limits.max_include_depth {
|
||||
return Err(vec![problem(
|
||||
self.bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"project_limit",
|
||||
format!("Include depth exceeds {}.", self.limits.max_include_depth),
|
||||
)]);
|
||||
}
|
||||
if !self.visited.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
if !self.bundle.files.contains_key(&path) {
|
||||
return Err(vec![problem(
|
||||
self.bundle,
|
||||
&at.path,
|
||||
at.span,
|
||||
"missing_include",
|
||||
format!("Included file '{path}' is missing."),
|
||||
)]);
|
||||
}
|
||||
self.depth = depth;
|
||||
return Ok(Some(path));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn include(
|
||||
&mut self,
|
||||
path: &str,
|
||||
raw: &str,
|
||||
span: Span,
|
||||
) -> Result<IncludeLocation, Vec<SourceDiagnostic>> {
|
||||
let target = resolve_include(path, raw)
|
||||
.map_err(|message| vec![problem(self.bundle, path, span, "invalid_path", message)])?;
|
||||
let location = SourceLocation {
|
||||
path: path.into(),
|
||||
span,
|
||||
};
|
||||
if self.edges >= self.limits.max_include_edges {
|
||||
return Err(vec![problem(
|
||||
self.bundle,
|
||||
path,
|
||||
span,
|
||||
"project_limit",
|
||||
format!(
|
||||
"Include graph exceeds {} edges.",
|
||||
self.limits.max_include_edges
|
||||
),
|
||||
)]);
|
||||
}
|
||||
self.edges += 1;
|
||||
self.children.push(Visit::Enter(
|
||||
target.clone(),
|
||||
self.depth.saturating_add(1),
|
||||
Some(location.clone()),
|
||||
));
|
||||
Ok(IncludeLocation { location, target })
|
||||
}
|
||||
|
||||
pub(crate) fn finish_file(&mut self, path: LogicalPath) {
|
||||
self.active.push(path);
|
||||
self.stack.push(Visit::Leave);
|
||||
self.stack.extend(self.children.drain(..).rev());
|
||||
}
|
||||
|
||||
pub(crate) fn finish(self) -> Result<(), Vec<SourceDiagnostic>> {
|
||||
if let Some(path) = self
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.find(|path| !self.visited.contains(*path))
|
||||
{
|
||||
return Err(vec![problem(
|
||||
self.bundle,
|
||||
path,
|
||||
Span::default(),
|
||||
"unreachable_file",
|
||||
"Supplied source file is not reachable from the entry file.",
|
||||
)]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_compile_once_keeps_include_order_and_cycle_source_location() {
|
||||
let mut bundle = SourceBundle {
|
||||
entry: "main.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("main.arcadia".into(), "arcadia 7; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" { include \"dir/left.arcadia\"; include \"dir/right.arcadia\"; }".into()),
|
||||
("dir/left.arcadia".into(), "include \"../shared.arcadia\";".into()),
|
||||
("dir/right.arcadia".into(), "include \"../shared.arcadia\";".into()),
|
||||
("shared.arcadia".into(), "// one shared ordinary fragment\n".into()),
|
||||
]),
|
||||
};
|
||||
let original = bundle.clone();
|
||||
// This is the existing initial ordinary pass, before schema migration's
|
||||
// separate output/reparse. It makes no public source8 acceptance claim.
|
||||
let (compiled, insertions) =
|
||||
super::super::compile_once(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert!(insertions.is_empty());
|
||||
assert_eq!(compiled.bundle, original);
|
||||
assert_eq!(compiled.index.declarations.len(), 1);
|
||||
assert_eq!(
|
||||
compiled
|
||||
.index
|
||||
.includes
|
||||
.iter()
|
||||
.map(|i| (i.location.path.as_str(), i.target.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("main.arcadia", "dir/left.arcadia"),
|
||||
("main.arcadia", "dir/right.arcadia"),
|
||||
("dir/left.arcadia", "shared.arcadia"),
|
||||
("dir/right.arcadia", "shared.arcadia"),
|
||||
]
|
||||
);
|
||||
let cycle = "// 雪\r\ninclude \"main.arcadia\";";
|
||||
bundle.files.insert("shared.arcadia".into(), cycle.into());
|
||||
let before = bundle.clone();
|
||||
let errors = super::super::compile_once(&bundle, ProjectLimits::default()).unwrap_err();
|
||||
assert_eq!(errors[0].diagnostic.code, "include_cycle");
|
||||
assert_eq!(errors[0].path, "shared.arcadia");
|
||||
assert_eq!(errors[0].diagnostic.line, 2);
|
||||
assert_eq!(
|
||||
&cycle[errors[0].diagnostic.span.start..errors[0].diagnostic.span.end],
|
||||
"include \"main.arcadia\";"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].diagnostic.message,
|
||||
"Include cycle: main.arcadia -> dir/left.arcadia -> shared.arcadia -> main.arcadia."
|
||||
);
|
||||
assert_eq!(bundle, before);
|
||||
}
|
||||
}
|
||||
@@ -449,6 +449,13 @@ pub fn plan_transition(
|
||||
request: &TransitionRequest,
|
||||
) -> Result<TransitionPlan, ModelError> {
|
||||
validate_candidate(project)?;
|
||||
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
|
||||
if request.sources.iter().any(is_analysis)
|
||||
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|
||||
|| mapping.target.as_ref().is_some_and(is_analysis)
|
||||
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
|
||||
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
|
||||
}
|
||||
let mut plan = TransitionPlan {
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
request: request.clone(),
|
||||
@@ -608,6 +615,9 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected.iter().any(is_analysis) {
|
||||
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
|
||||
}
|
||||
selected.retain(|id| {
|
||||
if omitted.contains(id) {
|
||||
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
|
||||
|
||||
@@ -180,6 +180,18 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
}
|
||||
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
// Bound analysis trees before architectural visitors can clone an element.
|
||||
// Admission itself never enters this public validator recursively.
|
||||
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
|
||||
let mut out = Vec::new();
|
||||
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
|
||||
return out;
|
||||
}
|
||||
validate_structure(p)
|
||||
}
|
||||
|
||||
/// Architecture/header validation without recursively re-entering analysis admission.
|
||||
pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let coverage = coverage::Index::new(p);
|
||||
let topology_index = crate::topology_context::TopologyIndex::new(p);
|
||||
|
||||
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
|
||||
assert_eq!(session.project(), &after);
|
||||
let fresh = Id::new_v4();
|
||||
let mut reidentified = after.clone();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
@@ -18,7 +18,6 @@ serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
|
||||
+64
-574
@@ -2,15 +2,11 @@ pub(crate) mod architecture_authoring;
|
||||
#[cfg(test)]
|
||||
mod architecture_authoring_tests;
|
||||
mod checkpoints;
|
||||
mod diagram_reveal;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod diagram_usages;
|
||||
mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
@@ -61,7 +57,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() {
|
||||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
@@ -77,26 +73,18 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)),
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let context = app.scenario_context().unwrap();
|
||||
for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] {
|
||||
for (area, offset) in [
|
||||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||
] {
|
||||
let before = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(before));
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
@@ -125,7 +113,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
scenario::ScrollArea::Page,
|
||||
scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
@@ -330,7 +318,6 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
@@ -384,8 +371,6 @@ pub enum Message {
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
FieldReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -430,18 +415,9 @@ pub enum Message {
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
SourceViewport(Option<Id>),
|
||||
SourceSymbols(
|
||||
crate::source_navigation::SymbolContext,
|
||||
crate::source_navigation::SymbolEvent,
|
||||
),
|
||||
SourceSymbolsReady(crate::source_navigation::Completion),
|
||||
DiagramUsage(crate::diagram_usage::Event),
|
||||
DiagramUsagesReady(crate::diagram_usage::Completion),
|
||||
DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size),
|
||||
SourceViewport,
|
||||
SourceForSelection,
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
SourceFindClose(crate::source_navigation::SymbolContext, Id),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
@@ -469,14 +445,10 @@ pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
reference_authoring: reference_authoring::State,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
menu_sequence: u64,
|
||||
pub canvas_document: Id,
|
||||
pub canvas_epoch: u64,
|
||||
/// Camera batches survive their own updates, never external presentation changes.
|
||||
pub camera_epoch: u64,
|
||||
camera_boundary_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
pub exact_strings: bool,
|
||||
pub document: Document,
|
||||
@@ -519,17 +491,6 @@ pub struct App {
|
||||
pub source_jump: Option<text_editor::Cursor>,
|
||||
source_select_line: bool,
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
source_find_instance: Id,
|
||||
pub source_symbols: crate::source_navigation::SymbolState,
|
||||
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
|
||||
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
|
||||
source_diagram_job: Option<Id>,
|
||||
diagram_lookup_in_flight: Option<Id>,
|
||||
pending_diagram_reveal: Option<(
|
||||
crate::diagram_usage::Context,
|
||||
crate::diagram_usage::Usage,
|
||||
u64,
|
||||
)>,
|
||||
pub model_issues: Vec<validation::Issue>,
|
||||
pub migration: Option<persistence::MigrationReport>,
|
||||
source_version: u64,
|
||||
@@ -586,13 +547,10 @@ impl App {
|
||||
checkpoint_close: None,
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
reference_authoring: Default::default(),
|
||||
source_tree: Default::default(),
|
||||
menu_sequence: 0,
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
camera_epoch: 0,
|
||||
camera_boundary_epoch: 0,
|
||||
selected_bend: None,
|
||||
exact_strings: false,
|
||||
document,
|
||||
@@ -638,13 +596,6 @@ impl App {
|
||||
source_jump: None,
|
||||
source_select_line: false,
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
source_find_instance: Id::new_v4(),
|
||||
source_symbols: crate::source_navigation::SymbolState::default(),
|
||||
diagram_usages: None,
|
||||
diagram_navigation_focus: None,
|
||||
source_diagram_job: None,
|
||||
diagram_lookup_in_flight: None,
|
||||
pending_diagram_reveal: None,
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_version: 0,
|
||||
@@ -698,7 +649,6 @@ impl App {
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
@@ -713,9 +663,6 @@ impl App {
|
||||
.map(|b| b.cursor)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
}
|
||||
fn selected_architecture_placement(&self) -> Option<Id> {
|
||||
self.active_diagram
|
||||
@@ -907,12 +854,8 @@ impl App {
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(self.project(), &command)
|
||||
.or_else(|| {
|
||||
self.behavior
|
||||
.topology
|
||||
.created_selection(self.project(), &command)
|
||||
});
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(created) = created {
|
||||
@@ -969,8 +912,6 @@ impl App {
|
||||
let canvas_size = self.canvas_size;
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
let navigation_in_flight = self.source_symbols.in_flight;
|
||||
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
@@ -982,8 +923,6 @@ impl App {
|
||||
self.canvas_size = canvas_size;
|
||||
self.source_version = source_version;
|
||||
self.lint_running = lint_running;
|
||||
self.source_symbols.in_flight = navigation_in_flight;
|
||||
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
self.status = "Project ready".into();
|
||||
@@ -1084,72 +1023,16 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::source_navigation::interaction::observe(
|
||||
crate::behavior::scenario::focus::root(
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(
|
||||
self,
|
||||
self.reference_overlay(crate::diagram_usage::view::overlay(self, base)),
|
||||
),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
),
|
||||
self.scenario_focus_owner(),
|
||||
),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
)
|
||||
crate::hosted::view::overlay(self, base)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
self.diagram_usages
|
||||
.as_ref()
|
||||
.map_or_else(Subscription::none, |panel| {
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !modifiers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match key {
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8),
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8),
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter)
|
||||
if status == iced::event::Status::Ignored =>
|
||||
{
|
||||
Some(0i8)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.with(panel.token)
|
||||
.map(|(token, step)| {
|
||||
Message::DiagramUsage(if step == 0 {
|
||||
crate::diagram_usage::Event::Activate(token)
|
||||
} else {
|
||||
crate::diagram_usage::Event::Move(token, step)
|
||||
})
|
||||
})
|
||||
}),
|
||||
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
// Use one captured-Escape bridge: a focused Find input consumes
|
||||
// Escape, while modal priority remains in the ordinary handler.
|
||||
behavior::creation::subscription(
|
||||
self.behavior.creation.has_pending()
|
||||
|| self.diagram_usages.is_some()
|
||||
|| self.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.source_symbols.open || self.source_navigation.open)
|
||||
&& self.architecture.is_none()),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
behavior::creation::subscription(self.behavior.creation.has_pending())
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
@@ -1194,201 +1077,6 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let usage_viewport = self.diagram_reveal_viewport(&message);
|
||||
let window_resized = matches!(&message, Message::WindowResized(_));
|
||||
// Reject stale measurements before legacy viewport handlers can change
|
||||
// dimensions or invalidate the exact pending destination epoch.
|
||||
if self.pending_diagram_reveal.is_some()
|
||||
&& Self::is_diagram_viewport(&message)
|
||||
&& usage_viewport.is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return self.behavior_control(intent);
|
||||
}
|
||||
let camera = matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let task = self.update_inner(message);
|
||||
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
|
||||
*epoch = self.canvas_epoch;
|
||||
}
|
||||
if let Some(surface) = usage_viewport {
|
||||
self.finish_diagram_reveal(surface);
|
||||
}
|
||||
if self
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none())
|
||||
{
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
use behavior::canvas_workspace::{self, Event as Camera, Scope};
|
||||
use behavior::scenario::CanvasEvent as Scenario;
|
||||
use behavior::topology::CanvasEvent as Process;
|
||||
if Scope::input_owner(self) != Some(intent.scope) {
|
||||
return Task::none();
|
||||
}
|
||||
let mut message = *intent.message;
|
||||
let scope = intent.scope;
|
||||
let valid_scenario = |c: behavior::scenario::Context| {
|
||||
c.document == scope.document
|
||||
&& c.scenario == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
let valid_process = |c: behavior::topology::Context| {
|
||||
c.document == scope.document
|
||||
&& c.owner == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
// This whitelist contains only the production presentation controls.
|
||||
// A stamped toolbar cannot smuggle an arbitrary authoring command.
|
||||
match &mut message {
|
||||
Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_scenario(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_process(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::ScenarioCanvas(
|
||||
Scenario::Zoom(c, _)
|
||||
| Scenario::Choose(c, _)
|
||||
| Scenario::Focus(c)
|
||||
| Scenario::Shelf(c, _),
|
||||
) if valid_scenario(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::TopologyCanvas(
|
||||
Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c),
|
||||
) if valid_process(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
|
||||
if self.topology_context().is_some() => {}
|
||||
_ => return Task::none(),
|
||||
}
|
||||
let task = self.update(message);
|
||||
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
|
||||
// after the action, for Iced's final-view ordering.
|
||||
if self.camera_epoch == scope.epoch {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| {
|
||||
canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name))
|
||||
});
|
||||
Task::batch([task, focus])
|
||||
}
|
||||
fn camera_toolbar_epoch(&self, epoch: u64) -> bool {
|
||||
epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch
|
||||
}
|
||||
fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_epoch,
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
crate::behavior::selected_owner(self),
|
||||
self.page,
|
||||
self.window_size,
|
||||
crate::behavior::canvas_workspace::keyboard_enabled(self),
|
||||
self.behavior.has_pending(),
|
||||
self.draft_dirty(),
|
||||
self.source_dirty(),
|
||||
self.behavior.topology.camera_boundary(),
|
||||
self.behavior.scenario.camera_boundary(),
|
||||
)
|
||||
}
|
||||
fn update_inner(&mut self, message: Message) -> Task<Message> {
|
||||
let message = match message {
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
if context != self.source_symbol_context()
|
||||
|| instance != self.source_find_instance
|
||||
|| !self.source_navigation.open
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Only the checked envelope may enter the legacy Close handler.
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close)
|
||||
}
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
|
||||
return Task::none();
|
||||
}
|
||||
message => message,
|
||||
};
|
||||
if let Message::SourceSymbols(context, _) = &message
|
||||
&& (context.session != self.source_workspace.session()
|
||||
|| context.path != self.source_workspace.active)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Internal results and geometry do not constitute a new user intent.
|
||||
// Process query completion even behind a modal to release its worker slot;
|
||||
// its handler independently refuses navigation outside the live editor.
|
||||
if let Message::SourceSymbolsReady(completion) = message {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
if let Message::DiagramUsagesReady(completion) = message {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
) {
|
||||
self.source_interaction();
|
||||
}
|
||||
if let Message::DiagramUsage(event) = message {
|
||||
return self.diagram_usage_event(event);
|
||||
}
|
||||
if let Some(panel) = &self.diagram_usages {
|
||||
if matches!(&message, Message::Request(Pending::Close(_))) {
|
||||
self.diagram_usages = None;
|
||||
return self.update(message);
|
||||
}
|
||||
if matches!(&message, Message::Escape) {
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token));
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if let Some(task) = self.reference_message(&message) {
|
||||
return task;
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1453,7 +1141,7 @@ impl App {
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::SourceViewport
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
@@ -1499,7 +1187,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::SourceViewport
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1537,7 +1225,7 @@ impl App {
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::SourceViewport
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
@@ -1611,7 +1299,6 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1679,23 +1366,8 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
|| self.hosted.visible
|
||||
|| self.workflow.is_some()
|
||||
|| self.architecture.is_some()
|
||||
|| self.model_menu.is_some()
|
||||
|| self.file_prompt.is_some()
|
||||
|| self.delete_pending
|
||||
|| self.pending.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
Message::Home(event) => return self.home_event(event),
|
||||
Message::CheckpointTick => return self.checkpoint_tick(),
|
||||
@@ -1711,18 +1383,6 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::FormShown(_, mount) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.scenario_focus_owner().is_some()
|
||||
&& self.behavior.scenario.take_form_focus(mount)
|
||||
{
|
||||
return scenario::focus::task(self.scenario_focus_owner().unwrap());
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
@@ -1747,9 +1407,9 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Details may report more than once in a frame. Accept the
|
||||
// current owner's passive clamps across an epoch bump while
|
||||
// still rejecting another document, owner or revision.
|
||||
// Canvas and outline notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
// while still rejecting another owner/document/revision.
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
@@ -1760,49 +1420,6 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.scenario_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.scenario == context.scenario
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
|
||||
}
|
||||
input => {
|
||||
if self.behavior.scenario.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1816,11 +1433,9 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
if self.behavior.scenario.zoom(scale) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.scenario = state;
|
||||
}
|
||||
CanvasEvent::OpenUse(_, occurrence) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
@@ -1911,22 +1526,13 @@ impl App {
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = if tool == scenario::Tool::Lifeline {
|
||||
state
|
||||
.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
)
|
||||
};
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -2148,17 +1754,7 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.source_symbols.open);
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.source_active() {
|
||||
self.behavior.acknowledge_commit();
|
||||
self.status = "Behavior form cancelled; no staged source was created.".into();
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
}
|
||||
@@ -2166,12 +1762,6 @@ impl App {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
if restore_source_focus {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -2227,9 +1817,6 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => {
|
||||
return Task::none();
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
@@ -2470,7 +2057,7 @@ impl App {
|
||||
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
|
||||
match event {
|
||||
crate::workflow::Event::Semantic(event) => {
|
||||
state.update(self.project(), *event)
|
||||
state.update(self.project(), event)
|
||||
}
|
||||
crate::workflow::Event::Apply => {
|
||||
let focus = state.target(self.project());
|
||||
@@ -2734,13 +2321,10 @@ impl App {
|
||||
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
|
||||
&& (self.source_dirty() || self.draft_dirty())
|
||||
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
|
||||
{
|
||||
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let source_was_open = self.behavior.source_open();
|
||||
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
|
||||
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -2758,11 +2342,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if !source_was_open && self.behavior.source_open() {
|
||||
return iced::widget::operation::focus(
|
||||
behavior::source_draft::NAME_INPUT,
|
||||
);
|
||||
}
|
||||
if focus_name {
|
||||
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
|
||||
}
|
||||
@@ -2958,19 +2537,13 @@ impl App {
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
let changed = if redo {
|
||||
self.document.redo()
|
||||
if redo {
|
||||
self.document.redo();
|
||||
} else {
|
||||
self.document.undo()
|
||||
};
|
||||
if changed {
|
||||
self.sync();
|
||||
self.document.undo();
|
||||
}
|
||||
self.status = if !changed && redo {
|
||||
"Nothing to redo"
|
||||
} else if !changed {
|
||||
"Nothing to undo"
|
||||
} else if redo {
|
||||
self.sync();
|
||||
self.status = if redo {
|
||||
"Model change redone"
|
||||
} else {
|
||||
"Model change undone"
|
||||
@@ -3223,21 +2796,12 @@ impl App {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
if self.path.as_ref() != Some(&receipt.target) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.document.invalidate_saved_baseline();
|
||||
}
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
@@ -3294,28 +2858,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
|
||||
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
|
||||
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
|
||||
Message::SourceSymbolsReady(completion) => {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
Message::DiagramUsage(event) => return self.diagram_usage_event(event),
|
||||
Message::DiagramUsagesReady(completion) => {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
Message::DiagramRevealViewport(context, epoch, size) => {
|
||||
if self
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.is_some_and(|(c, _, e)| *c == context && *e == epoch)
|
||||
&& self
|
||||
.diagram_reveal_viewport(&Message::DiagramRevealViewport(
|
||||
context, epoch, size,
|
||||
))
|
||||
.is_some()
|
||||
{
|
||||
return self.handle(Message::CanvasResized(size));
|
||||
Message::SourceViewport => {
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return iced::widget::operation::focus("source-editor");
|
||||
}
|
||||
}
|
||||
Message::ApplySource => {
|
||||
@@ -3362,7 +2911,6 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
self.page = Page::Text;
|
||||
self.source_find_instance = Id::new_v4();
|
||||
self.source_navigation.open = true;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
return iced::widget::operation::focus(if go_to_line {
|
||||
@@ -3371,22 +2919,13 @@ impl App {
|
||||
"source-find"
|
||||
});
|
||||
}
|
||||
Event::Close => {
|
||||
let was_open = self.source_navigation.open;
|
||||
self.source_navigation.open = false;
|
||||
if was_open {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
}
|
||||
Event::Close => self.source_navigation.open = false,
|
||||
Event::Query(query) => {
|
||||
self.source_navigation.query = query;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
}
|
||||
Event::Next(backwards) if self.page == Page::Text => {
|
||||
self.source_jump = self.source_navigation.next(backwards);
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
if self.source_jump.is_none() {
|
||||
self.status = "No matching source text.".into();
|
||||
}
|
||||
@@ -3397,7 +2936,8 @@ impl App {
|
||||
.destination(self.source_editor.line_count())
|
||||
{
|
||||
Ok(cursor) => {
|
||||
self.queue_source_reveal(cursor, true);
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = true;
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
},
|
||||
@@ -3480,9 +3020,6 @@ impl App {
|
||||
}
|
||||
self.sync_source();
|
||||
}
|
||||
if self.path.as_ref() != Some(&receipt.path) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
self.hosted.session = None;
|
||||
@@ -3694,52 +3231,6 @@ impl App {
|
||||
id,
|
||||
));
|
||||
}
|
||||
// Refining a leaf moves its explicit responsibilities to the new leaf.
|
||||
// Later children inherit only the existing unambiguous performer summary.
|
||||
if self.create_kind.is_work()
|
||||
&& let Some(owner) = parent
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
|
||||
{
|
||||
let performer = oa_core::validation::allocation_summary(self.project(), owner);
|
||||
let leaf = !self
|
||||
.project()
|
||||
.children(owner)
|
||||
.any(|e| e.kind() == self.create_kind);
|
||||
let responsibilities: Vec<_> = self
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.target() == owner
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::Allocation | RelationKind::RoleAssignment
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if leaf && !responsibilities.is_empty() {
|
||||
for original in responsibilities {
|
||||
let mut responsibility = original.clone();
|
||||
match &mut responsibility.body {
|
||||
RelationBody::Allocation { target, .. }
|
||||
| RelationBody::RoleAssignment { target, .. } => *target = id,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
commands.push(Command::EditRelation(original.id, responsibility));
|
||||
}
|
||||
} else if let Some(performer) = performer {
|
||||
commands.push(Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Performs".into(),
|
||||
performer,
|
||||
id,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.create_kind == Kind::Occurrence
|
||||
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
|
||||
&& let Some(steps) = owner.steps()
|
||||
@@ -3757,13 +3248,19 @@ impl App {
|
||||
.get(&diagram)
|
||||
.is_none_or(|d| d.projection.includes(self.create_kind))
|
||||
{
|
||||
let mut p = match execute_batch(self.project(), &commands) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return;
|
||||
let mut p = self.project().clone();
|
||||
for command in &commands {
|
||||
if let Command::CreateDiagram(d) = command {
|
||||
p.diagrams.insert(d.id, d.clone());
|
||||
}
|
||||
};
|
||||
}
|
||||
p.elements.insert(
|
||||
id,
|
||||
match &commands[0] {
|
||||
Command::CreateElement(e) => e.clone(),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let owner = if allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -4081,9 +3578,8 @@ impl App {
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.source_version += 1;
|
||||
@@ -4135,13 +3631,11 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
self.source_jump = Some(text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
});
|
||||
self.source_select_line = true;
|
||||
}
|
||||
|
||||
fn create_source_file(&mut self) -> Result<(), String> {
|
||||
@@ -4751,10 +4245,6 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
#[path = "app/save_as_tests.rs"]
|
||||
mod save_as_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app/repair_save_state_tests.rs"]
|
||||
mod repair_save_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_project_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -70,7 +70,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
|
||||
@@ -225,7 +225,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
@@ -300,7 +299,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
|
||||
@@ -214,11 +214,6 @@ impl App {
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
)
|
||||
}
|
||||
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
|
||||
self.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
|
||||
.map(|(context, _, epoch)| (*context, *epoch))
|
||||
}
|
||||
|
||||
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
|
||||
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| *epoch != self.canvas_epoch
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (surface, size, valid) = match message {
|
||||
Message::DiagramRevealViewport(c, e, size) => (
|
||||
usage.surface,
|
||||
*size,
|
||||
c == context
|
||||
&& e == epoch
|
||||
&& self.page == Page::Diagram
|
||||
&& Some(usage.surface.id()) == self.active_diagram,
|
||||
),
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Process(c.owner),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Scenario(c.scenario),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
(valid
|
||||
&& surface == usage.surface
|
||||
&& size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
&& size.width > 0.
|
||||
&& size.height > 0.)
|
||||
.then_some(surface)
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
|
||||
if !matches!(
|
||||
message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
| Message::DiagramUsage(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
) {
|
||||
self.pending_diagram_reveal = None;
|
||||
if !Self::is_navigation_camera(message) {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_navigation_camera(message: &Message) -> bool {
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return Self::is_navigation_camera(&intent.message);
|
||||
}
|
||||
matches!(
|
||||
message,
|
||||
Message::Pan(_)
|
||||
| Message::Zoom(_)
|
||||
| Message::Fit
|
||||
| Message::TopologyCanvas(
|
||||
behavior::topology::CanvasEvent::Camera(..)
|
||||
| behavior::topology::CanvasEvent::Zoom(..)
|
||||
| behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| behavior::topology::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::ScenarioCanvas(
|
||||
behavior::scenario::CanvasEvent::Camera(..)
|
||||
| behavior::scenario::CanvasEvent::Zoom(..)
|
||||
| behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| behavior::scenario::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::StateDiagram(
|
||||
behavior::state_edit::Event::Pan(..)
|
||||
| behavior::state_edit::Event::Zoom(..)
|
||||
| behavior::state_edit::Event::Fit(..)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
|
||||
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
|
||||
return;
|
||||
};
|
||||
if context.document == self.canvas_document
|
||||
&& context.revision == self.document.revision()
|
||||
&& usage.surface == surface
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
{
|
||||
// The normal viewport handler has now stored the measured size.
|
||||
// Target identity was validated for this revision before navigation.
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
}
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
//! Independent first-layout navigation oracles, through the real App boundary.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target, Usage};
|
||||
use iced::{Size, Vector};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Canvas {
|
||||
Architecture,
|
||||
Process,
|
||||
Scenario,
|
||||
State,
|
||||
}
|
||||
const CANVASES: [Canvas; 4] = [
|
||||
Canvas::Architecture,
|
||||
Canvas::Process,
|
||||
Canvas::Scenario,
|
||||
Canvas::State,
|
||||
];
|
||||
const WIDE: Size = Size::new(1200., 700.);
|
||||
const ACTUAL: Size = Size::new(500., 300.);
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
|
||||
.unwrap();
|
||||
let (surface, subject) = match canvas {
|
||||
Canvas::Architecture => {
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(
|
||||
diagram,
|
||||
work,
|
||||
Position {
|
||||
x: 12_000.,
|
||||
y: 9_000.,
|
||||
},
|
||||
);
|
||||
(Surface::Saved(diagram), work)
|
||||
}
|
||||
Canvas::Process | Canvas::Scenario => {
|
||||
let kind = if matches!(canvas, Canvas::Process) {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::Scenario
|
||||
};
|
||||
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
|
||||
let occurrence = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
|
||||
.unwrap();
|
||||
*p.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
|
||||
if !steps.contains(&occurrence) {
|
||||
steps.push(occurrence);
|
||||
}
|
||||
(
|
||||
if matches!(canvas, Canvas::Process) {
|
||||
Surface::Process(owner)
|
||||
} else {
|
||||
Surface::Scenario(owner)
|
||||
},
|
||||
occurrence,
|
||||
)
|
||||
}
|
||||
Canvas::State => {
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = p
|
||||
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "ready_view".into(),
|
||||
name: "Ready view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
p.place(
|
||||
diagram,
|
||||
state,
|
||||
Position {
|
||||
x: 8_000.,
|
||||
y: 11_000.,
|
||||
},
|
||||
);
|
||||
(Surface::Saved(diagram), state)
|
||||
}
|
||||
};
|
||||
let mut app = App::with_project(p);
|
||||
let found = usage::collect(
|
||||
app.project(),
|
||||
subject,
|
||||
&app.behavior.topology,
|
||||
&app.behavior.scenario,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|u| u.surface == surface && u.target != Target::Whole)
|
||||
.unwrap();
|
||||
app.canvas_size = Some(WIDE);
|
||||
app.behavior.diagram.viewport = Some(WIDE);
|
||||
if let Surface::Process(owner) = surface {
|
||||
app.behavior.topology.enter(app.canvas_document, owner);
|
||||
app.behavior.topology.viewport(WIDE);
|
||||
}
|
||||
if let Surface::Scenario(owner) = surface {
|
||||
app.behavior.scenario.enter(app.canvas_document, owner);
|
||||
app.behavior.scenario.viewport(WIDE);
|
||||
}
|
||||
app.page = Page::Text;
|
||||
(app, found)
|
||||
}
|
||||
|
||||
fn measurement(
|
||||
canvas: Canvas,
|
||||
context: Context,
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
behavior::topology::Context {
|
||||
document: context.document,
|
||||
owner: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
|
||||
behavior::state_edit::Context {
|
||||
document: context.document,
|
||||
diagram: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(*context, *epoch)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
// All independently authored fixture targets fit ACTUAL at the readable cap.
|
||||
// This oracle does not call the production camera/reveal implementation.
|
||||
let zoom = 1.25;
|
||||
let pan = Vector::new(
|
||||
250. - target.bounds.center_x() * zoom,
|
||||
150. - target.bounds.center_y() * zoom,
|
||||
);
|
||||
match canvas {
|
||||
Canvas::Architecture => {
|
||||
assert_eq!(app.canvas_size, Some(ACTUAL));
|
||||
assert_eq!(app.zoom, zoom);
|
||||
assert_eq!(app.pan, pan);
|
||||
assert!(!app.fit_to_view);
|
||||
}
|
||||
Canvas::State => {
|
||||
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
|
||||
assert_eq!(app.behavior.diagram.zoom, zoom);
|
||||
assert_eq!(app.behavior.diagram.pan, pan);
|
||||
assert!(!app.behavior.diagram.fit);
|
||||
}
|
||||
Canvas::Process => {
|
||||
let mut expected = behavior::topology::State::default();
|
||||
expected.enter(app.canvas_document, target.surface.id());
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
Canvas::Scenario => {
|
||||
let mut expected = behavior::scenario::State::default();
|
||||
expected.enter(app.canvas_document, target.surface.id());
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
|
||||
for canvas in CANVASES {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
let drafts = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let history = (
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), drafts);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
// The exact delivered callback is a replay after the one-shot is consumed.
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_centered(&app, canvas, &target);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for canvas in CANVASES {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
let mut size = Size::new(111., 122.);
|
||||
match invalid {
|
||||
0 => stale.document = Id::new_v4(),
|
||||
1 => stale.revision = stale.revision.wrapping_add(1),
|
||||
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
|
||||
3 => size.width = f32::NAN,
|
||||
4 => size.height = f32::INFINITY,
|
||||
_ => size.width = 0.,
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
|
||||
for canvas in CANVASES {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch) = stamp(&app);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
assert_ne!(epoch, old_epoch);
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
|
||||
for canvas in CANVASES {
|
||||
for action in 0..3 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
|
||||
app.state_context().unwrap(),
|
||||
Vector::new(31., -17.),
|
||||
)),
|
||||
Canvas::Process => {
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
|
||||
app.topology_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
|
||||
))
|
||||
}
|
||||
Canvas::Scenario => {
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
|
||||
))
|
||||
}
|
||||
},
|
||||
1 => Message::Field("/name".into(), "Pending title edit".into()),
|
||||
_ => Message::Page(Page::Catalog),
|
||||
};
|
||||
send(&mut app, event);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_none(),
|
||||
"{canvas:?} action {action}"
|
||||
);
|
||||
let camera = app.camera_boundary();
|
||||
let page = app.page;
|
||||
// A stale layout callback at the already retained size cannot
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
"{canvas:?} action {action}"
|
||||
);
|
||||
assert_eq!(app.page, page);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
if action == 1 {
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
|
||||
Message::DiagramUsage(Event::Open(Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject,
|
||||
}))
|
||||
}
|
||||
|
||||
fn usage_navigation_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.workflow.is_none()
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
}
|
||||
|
||||
/// Admit at most one expensive lookup, including one accepted Project clone.
|
||||
/// A replacement panel waits for the previous worker's exact completion.
|
||||
fn start_diagram_lookup(&mut self) -> Task<Message> {
|
||||
let Some(panel) = self.diagram_usages.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let (token, context) = (panel.token, panel.context);
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.usage_navigation_available()
|
||||
{
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.loading = false;
|
||||
panel.notice = Some(
|
||||
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
let project = self.project().clone();
|
||||
let presentation = self.behavior.topology.usage_snapshot();
|
||||
self.diagram_lookup_in_flight = Some(token);
|
||||
Task::perform(
|
||||
async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let process = presentation.into_query_state();
|
||||
let scenario = behavior::scenario::State::default();
|
||||
usage::collect(&project, context.subject, &process, &scenario)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
|
||||
usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result,
|
||||
}
|
||||
},
|
||||
Message::DiagramUsagesReady,
|
||||
)
|
||||
}
|
||||
|
||||
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
|
||||
let Some(panel) = self.diagram_usages.as_mut() else {
|
||||
return Task::none();
|
||||
};
|
||||
panel.token = Id::new_v4();
|
||||
panel.context.revision = self.document.revision();
|
||||
panel.usages.clear();
|
||||
panel.loading = true;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
panel.destination = None;
|
||||
panel.notice = Some(notice.into());
|
||||
self.start_diagram_lookup()
|
||||
}
|
||||
|
||||
/// Must run before modal/input dispatch. A closed or replaced panel still
|
||||
/// owns its worker slot until this exact completion arrives.
|
||||
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
|
||||
if self.diagram_lookup_in_flight != Some(completion.token) {
|
||||
return Task::none();
|
||||
}
|
||||
self.diagram_lookup_in_flight = None;
|
||||
let Some(panel) = self.diagram_usages.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.context.document != self.canvas_document {
|
||||
self.diagram_usages = None;
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.revision != self.document.revision() {
|
||||
return self
|
||||
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
||||
}
|
||||
if panel.token != completion.token {
|
||||
// A newer Open/refresh waited without cloning or spawning a second job.
|
||||
return self.start_diagram_lookup();
|
||||
}
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if panel.context != completion.context || !panel.loading {
|
||||
panel.loading = false;
|
||||
panel.notice = Some(
|
||||
"This lookup no longer matches the requested model. Close and try again.".into(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
panel.loading = false;
|
||||
match completion.result {
|
||||
Ok(usages) => panel.usages = usages,
|
||||
Err(error) => panel.notice = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Selection validates one destination only; opening the chooser does the
|
||||
/// whole-project scan on its worker, never on the GUI thread.
|
||||
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
|
||||
match surface {
|
||||
Surface::Saved(diagram) => {
|
||||
usage::saved_usages_for(self.project(), subject, Some(diagram))
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
self.behavior
|
||||
.topology
|
||||
.diagram_usages(self.project(), owner, subject)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.behavior
|
||||
.scenario
|
||||
.diagram_usages(self.project(), owner, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if let Event::Open(context) = event {
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.project().contains(context.subject)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut panel = Panel::new(context, Vec::new());
|
||||
panel.loading = true;
|
||||
let filter_id = format!("diagram-usage-filter-{}", panel.token);
|
||||
self.diagram_usages = Some(panel);
|
||||
self.model_menu = None;
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
self.start_diagram_lookup(),
|
||||
iced::widget::operation::focus(filter_id),
|
||||
]);
|
||||
}
|
||||
let token = match &event {
|
||||
Event::Open(_) => unreachable!(),
|
||||
Event::Close(t)
|
||||
| Event::Search(t, _)
|
||||
| Event::Page(t, _)
|
||||
| Event::Move(t, _)
|
||||
| Event::Activate(t)
|
||||
| Event::Destination(t, _)
|
||||
| Event::Choose(t, _) => *t,
|
||||
};
|
||||
let Some(panel) = &self.diagram_usages else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.token != token {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Close(_)) {
|
||||
self.diagram_usages = None;
|
||||
if self.page == Page::Text {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.document != self.canvas_document {
|
||||
self.diagram_usages = None;
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.revision != self.document.revision() {
|
||||
return self
|
||||
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
||||
}
|
||||
if panel.loading {
|
||||
// Search and Close remain useful while loading; queued activation
|
||||
// must not perform a synchronous fallback or choose an old row.
|
||||
if let Event::Search(_, query) = event {
|
||||
self.diagram_usages.as_mut().unwrap().search = query;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Move(_, direction) => {
|
||||
let panel = self.diagram_usages.as_ref().unwrap();
|
||||
let count = panel.choices(self.project()).len();
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if count > 0 {
|
||||
panel.cursor = if direction < 0 {
|
||||
(panel.cursor + count - 1) % count
|
||||
} else {
|
||||
(panel.cursor + 1) % count
|
||||
};
|
||||
panel.page = panel.cursor / 6;
|
||||
}
|
||||
}
|
||||
Event::Activate(_) => {
|
||||
let panel = self.diagram_usages.as_ref().unwrap();
|
||||
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
|
||||
return self.diagram_usage_event(event);
|
||||
}
|
||||
}
|
||||
Event::Search(_, query) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.search = query;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
}
|
||||
Event::Page(_, page) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.page = page;
|
||||
panel.cursor = page.saturating_mul(6);
|
||||
}
|
||||
Event::Destination(_, surface) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
|
||||
panel.destination = surface;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
}
|
||||
}
|
||||
Event::Choose(_, requested) => {
|
||||
if !self.usage_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
|
||||
let fresh = self
|
||||
.current_surface_usages(subject, requested.surface)
|
||||
.into_iter()
|
||||
.find(|u| u.subject == requested.subject && u.target == requested.target);
|
||||
let Some(fresh) = fresh else {
|
||||
return self.refresh_diagram_lookup(
|
||||
"This representation changed. Review the refreshed destinations.",
|
||||
);
|
||||
};
|
||||
if self.reveal_diagram_usage(&fresh) {
|
||||
self.diagram_usages = None;
|
||||
self.status = format!(
|
||||
"{} · {} in {}",
|
||||
self.project().named(subject),
|
||||
fresh.role,
|
||||
self.project().named(fresh.surface.id())
|
||||
);
|
||||
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|
||||
|id| {
|
||||
iced::widget::operation::snap_to(
|
||||
id,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Event::Open(_) | Event::Close(_) => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
Target::Placement(id) => {
|
||||
let Some(node) = p
|
||||
.diagrams
|
||||
.get(&usage.surface.id())
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(node.element, Some(*id))
|
||||
}
|
||||
Target::Port { port, placement } => (*port, Some(*placement)),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => (*relation, Some(*placement)),
|
||||
Target::Edge(id)
|
||||
| Target::ProcessLink(id)
|
||||
| Target::ScenarioObject(id)
|
||||
| Target::AllocationGrouping { relation: id, .. }
|
||||
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
|
||||
Target::ProcessOccurrence(endpoint) => (
|
||||
endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
None,
|
||||
),
|
||||
};
|
||||
match usage.surface {
|
||||
Surface::Saved(id) => {
|
||||
let Some(diagram) = p.diagrams.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
));
|
||||
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: Some(id),
|
||||
placement,
|
||||
viewport: Some(viewport),
|
||||
zoom,
|
||||
pan,
|
||||
fit: false,
|
||||
..Default::default()
|
||||
};
|
||||
self.behavior.active_owner = Some(machine);
|
||||
self.page = Page::Behavior;
|
||||
} else {
|
||||
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 550.).max(280.),
|
||||
(self.window_size.height - 284.).max(240.),
|
||||
));
|
||||
(self.zoom, self.pan) =
|
||||
usage::camera_for(&usage.target, usage.bounds, viewport);
|
||||
self.fit_to_view = false;
|
||||
self.page = Page::Diagram;
|
||||
}
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
if !self.behavior.topology.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
self.connection = None;
|
||||
self.last_workspace_page = self.page;
|
||||
if previous_layer != self.layer {
|
||||
self.data_state = Default::default();
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = None;
|
||||
self.explorer_page = 0;
|
||||
}
|
||||
self.normalize_creation();
|
||||
self.reveal_explorer_selection();
|
||||
self.refresh_draft();
|
||||
self.diagram_navigation_focus = Some(usage::Focus {
|
||||
context: Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject: usage.subject,
|
||||
},
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject: usage.subject,
|
||||
},
|
||||
usage.clone(),
|
||||
self.canvas_epoch,
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
/// Validated presentation identity. Edit selection and Delete capture remain
|
||||
/// semantic; a port owner or allocation work box is only a visual marker.
|
||||
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
|
||||
let focus = self.diagram_navigation_focus.as_ref()?;
|
||||
let current_surface = match surface {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
}
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
&& focus.context.document == self.canvas_document
|
||||
&& focus.context.revision == self.document.revision()
|
||||
&& focus.selected == self.selected)
|
||||
.then_some(&focus.usage)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -213,14 +213,6 @@ impl App {
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
@@ -353,14 +345,6 @@ impl App {
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
|
||||
@@ -15,15 +15,18 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
|
||||
if let Some(context) = self.topology_context() {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
if state.reveal_selection(self.project(), context.owner, self.selected) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.topology = state;
|
||||
}
|
||||
Task::none()
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
@@ -49,47 +52,6 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
|
||||
input => {
|
||||
if self.behavior.topology.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -119,7 +81,6 @@ impl App {
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
@@ -157,18 +118,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before choosing a process tool.".into();
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
state.camera_zoom(self.project(), context.owner, zoom);
|
||||
self.behavior.topology = state;
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
@@ -192,9 +149,7 @@ impl App {
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior
|
||||
.topology
|
||||
.remember(context.owner, occurrence, target);
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
@@ -569,30 +524,32 @@ mod tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
@@ -600,12 +557,12 @@ mod tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
@@ -625,7 +582,6 @@ mod tests {
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
//! One transient chooser for the currently mounted Inspector or review field.
|
||||
//! Browsing never calls a draft setter or semantic planner.
|
||||
use super::{App, Message, Page, Pending};
|
||||
use crate::{
|
||||
forms::{
|
||||
Draft,
|
||||
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
|
||||
},
|
||||
navigation::InspectorTab,
|
||||
semantic_review::{self, RowAddress, RowEvent},
|
||||
workflow,
|
||||
};
|
||||
use iced::{Element, Rectangle, Task};
|
||||
use oa_core::{FacetSelector, Id, Layer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Host {
|
||||
Inspector,
|
||||
Review {
|
||||
address: Option<RowAddress>,
|
||||
facet: FacetSelector,
|
||||
},
|
||||
}
|
||||
impl Host {
|
||||
fn message(&self, event: chooser::Event) -> Message {
|
||||
match self {
|
||||
Self::Inspector => Message::FieldReferenceEvent(event),
|
||||
Self::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceEvent(event),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Opened {
|
||||
host: Host,
|
||||
request: adapter::Open,
|
||||
document_session: Id,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
chooser: chooser::State,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
opened: Option<Opened>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn reference_input_active(&self) -> bool {
|
||||
self.reference_authoring.opened.is_some()
|
||||
}
|
||||
|
||||
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
|
||||
match host {
|
||||
Host::Inspector
|
||||
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
|
||||
{
|
||||
self.draft.as_ref()
|
||||
}
|
||||
Host::Review { address, facet } => match self.workflow.as_ref()? {
|
||||
workflow::Workflow::Semantic(state) => {
|
||||
state.reference_draft(self.project(), *address, facet, path)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn reference_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.pending.is_none()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
|
||||
}
|
||||
fn reference_valid(&self, opened: &Opened) -> bool {
|
||||
self.reference_available()
|
||||
&& self.canvas_document == opened.document_session
|
||||
&& self.page == opened.page
|
||||
&& self.layer == opened.layer
|
||||
&& self.document.revision() == opened.chooser.stamp().context.revision
|
||||
&& self.project().id == opened.request.document
|
||||
&& self
|
||||
.reference_draft(&opened.host, &opened.request.path)
|
||||
.is_some_and(|draft| draft.reference_matches(&opened.request))
|
||||
}
|
||||
pub(super) fn reconcile_reference_chooser(&mut self) {
|
||||
if self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.is_some_and(|opened| !self.reference_valid(opened))
|
||||
{
|
||||
self.reference_authoring
|
||||
.opened
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.invalidate();
|
||||
self.reference_authoring.opened = None;
|
||||
}
|
||||
}
|
||||
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
|
||||
if !self.reference_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(draft) = self.reference_draft(&host, &request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(chooser) =
|
||||
draft.reference_catalog(self.project(), &request, self.document.revision())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.reference_authoring.opened = Some(Opened {
|
||||
host,
|
||||
request,
|
||||
document_session: self.canvas_document,
|
||||
page: self.page,
|
||||
layer: self.layer,
|
||||
chooser,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
|
||||
let Some(mut opened) = self.reference_authoring.opened.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.reference_valid(&opened) {
|
||||
return Task::none();
|
||||
}
|
||||
if opened.host != host {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
return Task::none();
|
||||
}
|
||||
match opened.chooser.update(event) {
|
||||
Outcome::Ignored | Outcome::Browsed => {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
Task::none()
|
||||
}
|
||||
Outcome::Closed(reason) => self.reference_focus(
|
||||
&opened,
|
||||
match reason {
|
||||
Close::Escape => Some(0),
|
||||
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
|
||||
Close::Outside | Close::WindowChanged => None,
|
||||
},
|
||||
),
|
||||
Outcome::Write { context, value } => {
|
||||
let intent = adapter::Set {
|
||||
context,
|
||||
generation: opened.request.generation,
|
||||
value,
|
||||
};
|
||||
let result = match &opened.host {
|
||||
Host::Inspector => self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The property editor closed.".to_owned())
|
||||
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
|
||||
Host::Review { address, facet } => match self.workflow.as_mut() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
state.set_reference(self.document.project(), *address, facet, intent)
|
||||
}
|
||||
_ => Err("The review editor closed.".into()),
|
||||
},
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
// A scoped write rotates field generation. The task targets the
|
||||
// newly rendered field, while a later structural edit makes it inert.
|
||||
self.reference_focus(&opened, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
|
||||
let Some(step) = step else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
if draft.reference_session() != opened.request.session {
|
||||
return Task::none();
|
||||
}
|
||||
trigger::focus(
|
||||
draft.reference_session(),
|
||||
draft.reference_generation(),
|
||||
&opened.request.path,
|
||||
step,
|
||||
)
|
||||
}
|
||||
/// Called before generic modal dispatch: the review owns a modal, and this
|
||||
/// chooser owns only its active field. All browser events stop here.
|
||||
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
|
||||
self.reconcile_reference_chooser();
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Some(self.open_reference(Host::Inspector, request.clone()))
|
||||
}
|
||||
Message::FieldReferenceEvent(event) => {
|
||||
Some(self.reference_event(Host::Inspector, event.clone()))
|
||||
}
|
||||
Message::Workflow(workflow::Event::Semantic(review_event)) => {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event,
|
||||
} = review_event.as_ref()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let host = Host::Review {
|
||||
address: *address,
|
||||
facet: facet.clone(),
|
||||
};
|
||||
match event {
|
||||
RowEvent::ReferenceOpen(request) => {
|
||||
Some(self.open_reference(host, request.clone()))
|
||||
}
|
||||
RowEvent::ReferenceEvent(event) => {
|
||||
Some(self.reference_event(host, event.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
let opened = self.reference_authoring.opened.as_ref().unwrap();
|
||||
let host = opened.host.clone();
|
||||
let event = opened.chooser.event(Action::Close(Close::Escape));
|
||||
Some(self.reference_event(host, event))
|
||||
}
|
||||
// Global shortcuts are also emitted for keys captured by the search
|
||||
// input. They must not apply drafts, navigate, or undo the model.
|
||||
Message::Save(_)
|
||||
| Message::ShortcutUndo(_)
|
||||
| Message::Request(Pending::Open | Pending::New)
|
||||
| Message::SourceNavigation(_)
|
||||
| Message::Page(Page::Guide)
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn reference_overlay<
|
||||
'a,
|
||||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||||
>(
|
||||
&'a self,
|
||||
content: Element<'a, Message, iced::Theme, R>,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let opened = self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.filter(|opened| self.reference_valid(opened));
|
||||
let host = opened
|
||||
.map(|opened| opened.host.clone())
|
||||
.unwrap_or(Host::Inspector);
|
||||
let focus_scope = if self.reference_available() {
|
||||
match self.workflow.as_ref() {
|
||||
None if self.inspector_tab == InspectorTab::Properties => {
|
||||
self.draft.as_ref().map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: false,
|
||||
})
|
||||
}
|
||||
Some(workflow::Workflow::Semantic(state)) => state
|
||||
.reference_form(self.project())
|
||||
.map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
chooser::widget::Popup::new(
|
||||
content,
|
||||
opened.map(|opened| &opened.chooser),
|
||||
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
|
||||
move |event| host.message(event),
|
||||
)
|
||||
.focus_scope(focus_scope)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,586 +0,0 @@
|
||||
use super::*;
|
||||
use crate::forms::Draft;
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const OWNER: Id = Id::from_u128(100);
|
||||
const BASE: u128 = 10_000;
|
||||
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
||||
|
||||
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
|
||||
let generated = p.add_element(kind, layer, name, parent).unwrap();
|
||||
let mut element = p.elements.remove(&generated).unwrap();
|
||||
element.id = id;
|
||||
element.alias = format!("object_{}", id.as_u128());
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.elements.insert(id, element);
|
||||
}
|
||||
fn catalog() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
p.name = "Reference catalog".into();
|
||||
element(
|
||||
&mut p,
|
||||
OWNER,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Edited record",
|
||||
None,
|
||||
);
|
||||
for n in (0..1_000).rev() {
|
||||
let id = Id::from_u128(BASE + n);
|
||||
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
|
||||
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(200),
|
||||
Kind::DataType,
|
||||
Layer::Sa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(201),
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
p
|
||||
}
|
||||
fn inspector(p: Project, selected: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(selected));
|
||||
app
|
||||
}
|
||||
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
|
||||
app.reference_draft(host, path).unwrap().reference_open(
|
||||
app.project(),
|
||||
path,
|
||||
Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn open(app: &mut App, host: &Host, path: &str) {
|
||||
let request = request(app, host, path);
|
||||
let message = match host {
|
||||
Host::Inspector => Message::FieldReferenceOpen(request),
|
||||
Host::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceOpen(request),
|
||||
}))
|
||||
}
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert!(
|
||||
app.reference_authoring.opened.is_some(),
|
||||
"field must open: {}",
|
||||
app.status
|
||||
);
|
||||
}
|
||||
fn event(app: &App, action: Action) -> Message {
|
||||
let opened = app.reference_authoring.opened.as_ref().unwrap();
|
||||
opened.host.message(opened.chooser.event(action))
|
||||
}
|
||||
fn search(app: &mut App, query: &str) {
|
||||
let serial = app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.query_serial()
|
||||
+ 1;
|
||||
let event = event(
|
||||
app,
|
||||
Action::Query {
|
||||
text: query.into(),
|
||||
serial,
|
||||
},
|
||||
);
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let event = event(app, Action::Choose(id));
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn raw(app: &App) -> Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn review(app: &App) -> &semantic_review::State {
|
||||
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
|
||||
panic!("Review is open")
|
||||
};
|
||||
state
|
||||
}
|
||||
fn send_review(app: &mut App, event: RowEvent) {
|
||||
let addressed = review(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
|
||||
}
|
||||
fn review_host(app: &App) -> Host {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
..
|
||||
} = review(app).address_event(RowEvent::Preview)
|
||||
else {
|
||||
panic!("Active facet")
|
||||
};
|
||||
Host::Review { address, facet }
|
||||
}
|
||||
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.as_u128()),
|
||||
name: "Rationale".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
|
||||
let p = catalog();
|
||||
let mut app = inspector(p.clone(), OWNER);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
// Malformed escaped text is unrelated to the selected reference. Browsing
|
||||
// and the scoped write must keep its exact spelling and inactive payload.
|
||||
app.draft.as_mut().unwrap().set_exact(true).unwrap();
|
||||
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
|
||||
let raw_before = raw(&app);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1_000
|
||||
);
|
||||
search(&mut app, "controller @controller_0720 OA");
|
||||
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
|
||||
assert_eq!(picker.count(), 1);
|
||||
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
// Captured search shortcuts cannot accidentally save/apply or undo a model.
|
||||
let _ = app.update(Message::Save(false));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
choose(&mut app, Id::from_u128(BASE + 720));
|
||||
let mut expected_raw = raw_before;
|
||||
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
|
||||
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
|
||||
assert_eq!(raw(&app), expected_raw);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Apply);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"Unrelated invalid text remains an Apply error"
|
||||
);
|
||||
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
|
||||
let _ = app.update(Message::Apply);
|
||||
let mut expected = p;
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record {
|
||||
supertype: Some(Id::from_u128(BASE + 720)),
|
||||
};
|
||||
}
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_ne!(accepted, before);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
|
||||
for (raw_id, expected) in [
|
||||
("-".into(), "invalid"),
|
||||
(Id::from_u128(999).to_string(), "missing"),
|
||||
(Id::from_u128(200).to_string(), "incompatible"),
|
||||
] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(raw_id))
|
||||
.unwrap();
|
||||
let original = raw(&app);
|
||||
let accepted = app.document.bundle().clone();
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let current = &app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.current;
|
||||
assert!(matches!(
|
||||
(expected, current),
|
||||
("invalid", chooser::Current::InvalidRaw(_))
|
||||
| ("missing", chooser::Current::Missing(_))
|
||||
| ("incompatible", chooser::Current::Incompatible(_))
|
||||
));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert_eq!(raw(&app), original);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let clear = event(&app, Action::Clear);
|
||||
let _ = app.update(clear);
|
||||
assert!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(PATH)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
|
||||
for change in 0..7 {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
|
||||
match change {
|
||||
0 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
|
||||
.unwrap();
|
||||
}
|
||||
1 => {
|
||||
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
|
||||
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
|
||||
}
|
||||
2 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
3 => {
|
||||
app.hosted.visible = true;
|
||||
}
|
||||
4 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_context(Some(json!({"different":"scope"})));
|
||||
}
|
||||
5 => {
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
Id::from_u128(BASE + 3),
|
||||
"Changed model".into(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app.source_workspace.edit(
|
||||
"unfinished source".into(),
|
||||
iced::widget::text_editor::Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let expected = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(delayed);
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let app = inspector(p, Id::from_u128(300));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let request = draft.reference_open(
|
||||
app.project(),
|
||||
"/baseline/semantic/facets/0/references/0/target",
|
||||
Rectangle::default(),
|
||||
);
|
||||
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
|
||||
let mut p = Project::empty();
|
||||
for n in 0..3 {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(BASE + n),
|
||||
Kind::ExchangeItem,
|
||||
Layer::Oa,
|
||||
"Signal item",
|
||||
None,
|
||||
);
|
||||
}
|
||||
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
|
||||
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
|
||||
},
|
||||
};
|
||||
let mut app = inspector(p, OWNER);
|
||||
let list = "/body/Oa/Shared/Event/body/Signal/items";
|
||||
let path = format!("{list}/0");
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
|
||||
let expected = raw(&app);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(list),
|
||||
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
|
||||
);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldVariant(
|
||||
"/body/Oa/Shared/Event/body".into(),
|
||||
json!({"Change":{"expression":"unfinished"}}),
|
||||
));
|
||||
let expected = raw(&app);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
|
||||
{
|
||||
for mode in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
for (id, layer, parent) in [
|
||||
(100, Layer::Oa, None),
|
||||
(101, Layer::Sa, None),
|
||||
(102, Layer::Sa, None),
|
||||
(103, Layer::Oa, Some(Id::from_u128(100))),
|
||||
(104, Layer::Sa, Some(Id::from_u128(101))),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
if parent.is_some() {
|
||||
Kind::DataField
|
||||
} else {
|
||||
Kind::DataType
|
||||
},
|
||||
layer,
|
||||
"Record",
|
||||
parent,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
let facet = match mode {
|
||||
0 => FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
1 => FacetSelector::Data(DataFacet::Constructor),
|
||||
_ => FacetSelector::Child(KeyPair {
|
||||
oa: Some(Id::from_u128(103)),
|
||||
sa: Some(Id::from_u128(104)),
|
||||
}),
|
||||
};
|
||||
let path = if mode == 2 {
|
||||
"/parent"
|
||||
} else {
|
||||
"/body/Sa/Shared/DataType/definition/Record/supertype"
|
||||
};
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
|
||||
if mode == 2 {
|
||||
// A paired attachment is a real catalog facet only after explicit
|
||||
// correspondence; selecting an unstaged synthetic pair is rejected.
|
||||
for (id, oa) in [(103, true), (104, false)] {
|
||||
let choice = crate::app::Choice {
|
||||
id: Id::from_u128(id),
|
||||
label: format!("Record · @object_{id}"),
|
||||
};
|
||||
send_review(
|
||||
&mut app,
|
||||
if oa {
|
||||
RowEvent::ReferenceOa(choice)
|
||||
} else {
|
||||
RowEvent::ReferenceSa(choice)
|
||||
},
|
||||
);
|
||||
}
|
||||
send_review(&mut app, RowEvent::AddReference);
|
||||
}
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let record = if mode == 2 {
|
||||
Id::from_u128(104)
|
||||
} else {
|
||||
Id::from_u128(101)
|
||||
};
|
||||
assert_eq!(
|
||||
app.reference_draft(&host, path)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.original,
|
||||
record
|
||||
);
|
||||
let before = review(&app).recovery_snapshot().unwrap();
|
||||
let plan = serde_json::to_value(&review(&app).plan).unwrap();
|
||||
let proposed = review(&app).proposed.clone();
|
||||
let source = app.document.bundle().clone();
|
||||
open(&mut app, &host, path);
|
||||
search(&mut app, "@object_102 SA");
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
|
||||
assert_eq!(review(&app).proposed, proposed);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let old = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
|
||||
);
|
||||
let switched = review(&app).recovery_snapshot().unwrap();
|
||||
let _ = app.update(old);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
|
||||
send_review(&mut app, RowEvent::Select(facet));
|
||||
open(&mut app, &host, path);
|
||||
choose(&mut app, Id::from_u128(102));
|
||||
let draft = app.reference_draft(&host, path).unwrap();
|
||||
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
|
||||
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
|
||||
assert_eq!(draft.materialize().unwrap(), expected_record);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"A choice only stages the active review candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod mounted;
|
||||
|
||||
#[test]
|
||||
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
|
||||
let mut p = Project::empty();
|
||||
for (id, layer) in [
|
||||
(100, Layer::Oa),
|
||||
(101, Layer::Sa),
|
||||
(102, Layer::Sa),
|
||||
(110, Layer::Oa),
|
||||
(111, Layer::Sa),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
Kind::DataType,
|
||||
layer,
|
||||
"Record",
|
||||
None,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(310),
|
||||
Id::from_u128(110),
|
||||
Id::from_u128(111),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
|
||||
let old_open = request(&app, &host, path);
|
||||
open(&mut app, &host, path);
|
||||
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
for event in [
|
||||
semantic_review::Event::ChooseTrace(crate::app::Choice {
|
||||
id: Id::from_u128(310),
|
||||
label: "Second exact trace".into(),
|
||||
}),
|
||||
semantic_review::Event::OpenTrace,
|
||||
] {
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
|
||||
}
|
||||
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
|
||||
let expected = review(&app).recovery_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(old_choice);
|
||||
let Host::Review { address, facet } = host else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event: RowEvent::ReferenceOpen(old_open),
|
||||
},
|
||||
)));
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
//! Real field + app-root overlay interaction. The null text renderer lets the
|
||||
//! same retained widget tree exercise focus and geometry without a GPU/window.
|
||||
use super::*;
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Layout, Shell, layout,
|
||||
widget::{Id as WidgetId, Operation, Tree, operation},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::{Space, column, container, opaque, scrollable, stack, text_input},
|
||||
};
|
||||
use oa_core::transitions::TransitionDirection;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard;
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
values: Vec<(WidgetId, Rectangle, bool)>,
|
||||
scrolls: Vec<(Rectangle, Vector)>,
|
||||
}
|
||||
impl Operation for Fields {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.values.push((id.clone(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((bounds, translation));
|
||||
}
|
||||
}
|
||||
impl Fields {
|
||||
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
|
||||
self.values
|
||||
.iter()
|
||||
.find(|(actual, _, _)| actual == id)
|
||||
.map(|(_, bounds, focused)| (*bounds, *focused))
|
||||
.expect("Mounted field identity")
|
||||
}
|
||||
}
|
||||
fn view(
|
||||
app: &App,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
) -> Element<'_, Message, Theme, ()> {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let form = column![
|
||||
text_input("Name", &draft.inputs["/name"].raw)
|
||||
.id("before-reference")
|
||||
.on_input(|raw| Message::Field("/name".into(), raw)),
|
||||
adapter::field(draft, app.project(), PATH),
|
||||
text_input("Description", &draft.inputs["/description"].raw)
|
||||
.id("after-reference")
|
||||
.on_input(|raw| Message::Field("/description".into(), raw)),
|
||||
]
|
||||
.spacing(9);
|
||||
let form = trigger::scope(form, draft.reference_session());
|
||||
let base: Element<'_, Message, Theme, ()> = if scrolled {
|
||||
container(
|
||||
scrollable(column![Space::new().height(500), form])
|
||||
.id("reference-scroll")
|
||||
.height(180),
|
||||
)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
container(form).width(300).height(iced::Fill).into()
|
||||
};
|
||||
let base: Element<'_, Message, Theme, ()> = if nested {
|
||||
scrollable(column![Space::new().height(400), base])
|
||||
.id("outer-reference-scroll")
|
||||
.height(180)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let base = if show_review {
|
||||
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
match state.reference_form(app.project()) {
|
||||
Some(draft) => {
|
||||
let mut content = column![];
|
||||
if let Some(raw) = &state.operation_omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", raw)
|
||||
.id("review-rationale")
|
||||
.on_input(|raw| {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::OperationRationale(raw)),
|
||||
))
|
||||
}),
|
||||
);
|
||||
}
|
||||
content = content.push(trigger::scope(
|
||||
adapter::field(draft, app.project(), "/parent").map(|message| {
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::ReferenceOpen(request)),
|
||||
))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}),
|
||||
draft.reference_session(),
|
||||
));
|
||||
opaque(container(trigger::modal(content)).width(300).height(180))
|
||||
}
|
||||
None => Space::new().into(),
|
||||
}
|
||||
}
|
||||
_ => Space::new().into(),
|
||||
};
|
||||
stack![base, review].into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
app.reference_overlay(base)
|
||||
}
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(360., 320.),
|
||||
scrolled: false,
|
||||
nested: false,
|
||||
show_review: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn frame(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Fields, Option<Rectangle>) {
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (input, point) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
input,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
input,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut fields = Fields::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut fields);
|
||||
(
|
||||
messages,
|
||||
fields,
|
||||
overlay_node.map(|node| node.children()[0].bounds()),
|
||||
)
|
||||
}
|
||||
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
|
||||
self.frame(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
async fn perform(&mut self, app: &App, task: Task<Message>) {
|
||||
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
|
||||
return;
|
||||
};
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Chooser only requests scoped widget focus")
|
||||
};
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
loop {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), operation.as_mut());
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => operation = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
self.perform(app, task).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn key(key: keyboard::key::Named, shift: bool) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
},
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
|
||||
raw.chars()
|
||||
.map(|character| {
|
||||
let raw = character.to_string();
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(raw.clone().into()),
|
||||
modified_key: keyboard::Key::Character(raw.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(raw.into()),
|
||||
repeat: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("before-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Tab reaches the real reference trigger"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let popup = mount
|
||||
.frame(&app, &[])
|
||||
.2
|
||||
.expect("App root overlay is mounted");
|
||||
assert!(
|
||||
popup.x >= 0.
|
||||
&& popup.y >= 0.
|
||||
&& popup.x + popup.width <= 360.
|
||||
&& popup.y + popup.height <= 320.
|
||||
);
|
||||
let mut inputs = typing("@controller_0720");
|
||||
inputs.push((key(keyboard::key::Named::Escape, false), None));
|
||||
let messages = mount.frame(&app, &inputs).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Escape returns to its exact reference field"
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
|
||||
.0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&WidgetId::new(expected)).1,
|
||||
"Chooser Tab stays within the originating form"
|
||||
);
|
||||
}
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
|
||||
{
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut mount = Mount::default();
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("@controller_0720")).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1);
|
||||
let delayed_focus = app.update(messages.into_iter().next().unwrap());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(PATH),
|
||||
Some(&json!(Id::from_u128(BASE + 720)))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
// A structural edit occurs after Choose but before its asynchronous focus.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, Value::Null)
|
||||
.unwrap();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
mount.perform(&app, delayed_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"A missing old field target makes no focus changes"
|
||||
);
|
||||
assert!(
|
||||
!fields
|
||||
.field(&trigger::field_id(
|
||||
app.draft.as_ref().unwrap().reference_generation(),
|
||||
PATH
|
||||
))
|
||||
.1
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
..Default::default()
|
||||
};
|
||||
mount.frame(&app, &[]);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
|
||||
)
|
||||
.await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let content = fields.field(&id).0;
|
||||
let (viewport, offset) = fields.scrolls[0];
|
||||
assert!(offset.y > 400., "Fixture is genuinely scrolled");
|
||||
let expected = Rectangle {
|
||||
x: content.x - offset.x,
|
||||
y: content.y - offset.y,
|
||||
..content
|
||||
}
|
||||
.intersection(&viewport)
|
||||
.unwrap();
|
||||
let messages = mount.click(&app, expected.center());
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Scrolled field should emit one actual Open")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let close = app.update(Message::Escape);
|
||||
mount.perform(&app, close).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Focused scrolled field opens without a pointer")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
|
||||
for nested in [false, true] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
nested,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"Keyboard reveal must not author any input"
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).1);
|
||||
assert!(
|
||||
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
|
||||
"Every enclosing viewport reveals fresh focus"
|
||||
);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
assert!(
|
||||
fields.field(&id).0.y - offset >= 180.,
|
||||
"The next reference starts below the fold"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(messages.is_empty());
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(focused);
|
||||
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"A newly revealed field opens through Enter"
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if nested {
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"outer-reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(
|
||||
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
|
||||
for backwards in [false, true] {
|
||||
let app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let forward = [
|
||||
WidgetId::new("before-reference"),
|
||||
reference.clone(),
|
||||
WidgetId::new("after-reference"),
|
||||
];
|
||||
let reverse = [
|
||||
WidgetId::new("after-reference"),
|
||||
reference,
|
||||
WidgetId::new("before-reference"),
|
||||
];
|
||||
let expected = if backwards { &reverse } else { &forward };
|
||||
for n in 0..7 {
|
||||
let (messages, fields, popup) =
|
||||
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(fields.field(&expected[n % 3]).1);
|
||||
assert_eq!(
|
||||
fields
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(_, _, focused)| *focused)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(raw(&app), original);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let source = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let session = app.draft.as_ref().unwrap().reference_session();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().reference_session(),
|
||||
session,
|
||||
"Old Inspector really remains mounted"
|
||||
);
|
||||
assert_eq!(
|
||||
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
|
||||
id
|
||||
);
|
||||
let review_before = review(&app).recovery_snapshot().unwrap();
|
||||
let review_field = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
!fields.field(&id).1,
|
||||
"A live old field underneath a modal cannot receive the stale focus task"
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert!(!fields.field(&review_field).1);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(
|
||||
fields.field(&review_field).1,
|
||||
"Review entry ignores retained focus under its modal"
|
||||
);
|
||||
assert!(
|
||||
!fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Modal focus acquisition deactivates background inputs"
|
||||
);
|
||||
let messages = mount.frame(&app, &typing("x")).0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"A character at the focused Review trigger cannot type into the Inspector below it"
|
||||
);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(fields.field(&review_field).1);
|
||||
assert!(!fields.field(&id).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let saved = review(&app).recovery_snapshot().unwrap();
|
||||
let id = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Fixture retains Inspector focus before the modal receives input"
|
||||
);
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert!(matches!(
|
||||
&app.reference_authoring.opened.as_ref().unwrap().host,
|
||||
Host::Review { .. }
|
||||
));
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
let messages = mount.click(&app, Point::new(359., 319.));
|
||||
assert!(matches!(
|
||||
messages.as_slice(),
|
||||
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
|
||||
semantic_review::Event::Row {
|
||||
event: RowEvent::ReferenceEvent(chooser::Event {
|
||||
action: Action::Close(Close::Outside),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(task).is_none(),
|
||||
"Outside dismissal must not request focus or apply"
|
||||
);
|
||||
}
|
||||
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
|
||||
{
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|element| element.alias == "operation_probe_43")
|
||||
.unwrap()
|
||||
.id;
|
||||
let allocation = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|relation| relation.alias == "operation_probe_30")
|
||||
.unwrap()
|
||||
.id;
|
||||
// The boundary fixture deliberately has no selected operation. Author one
|
||||
// through the validated command path before testing reverse omission.
|
||||
let mut message = p.elements[&sa].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = message.shared_mut()
|
||||
else {
|
||||
panic!("The operation fixture must contain an Exchange call")
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
|
||||
assert!(matches!(
|
||||
oa_core::operations::resolve(&p, sa).unwrap(),
|
||||
oa_core::operations::OperationResolution::Selected(value)
|
||||
if value.allocation == allocation
|
||||
));
|
||||
let inspector_id = Id::new_v4();
|
||||
element(
|
||||
&mut p,
|
||||
inspector_id,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Inspector record",
|
||||
None,
|
||||
);
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
|
||||
.unwrap();
|
||||
let oa = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Operational message",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(oa);
|
||||
let trace_id = Id::new_v4();
|
||||
trace(&mut p, trace_id, oa, sa);
|
||||
let mut app = inspector(p, inspector_id);
|
||||
let source = app.document.bundle().clone();
|
||||
let inspector_raw = raw(&app);
|
||||
let _ = app.update(Message::StartSemanticReview(trace_id));
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Direction(TransitionDirection::SaToOa),
|
||||
)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send_review(&mut app, RowEvent::OperationOmission(true));
|
||||
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
|
||||
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
|
||||
let candidate = serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.recovery_snapshot(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("review-rationale")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("Engineering reason")).0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
"Engineering reason".chars().count(),
|
||||
"Every key reaches the legitimate sibling inside Review"
|
||||
);
|
||||
assert!(messages.iter().all(|message| matches!(
|
||||
message,
|
||||
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
|
||||
event: RowEvent::OperationRationale(_),
|
||||
..
|
||||
})
|
||||
)));
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
review(&app).operation_omission.as_deref(),
|
||||
Some("Engineering reason")
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.field(&WidgetId::new("review-rationale"))
|
||||
.1
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
)
|
||||
.unwrap(),
|
||||
candidate
|
||||
);
|
||||
assert_eq!(raw(&app), inspector_raw);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
|
||||
}
|
||||
fn row_event(app: &mut App, event: RowEvent) {
|
||||
let event = state(app).address_event(event);
|
||||
send(app, Message::Workflow(Event::semantic(event)));
|
||||
send(app, Message::Workflow(Event::Semantic(event)));
|
||||
}
|
||||
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
//! Current draft navigation, isolated from model application and disk discovery.
|
||||
use super::*;
|
||||
use crate::source_navigation::{self as nav, query};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn admission_notice(error: query::AdmissionError) -> String {
|
||||
use nav::symbols::{BuildError, InputProblem, Resource};
|
||||
let (limit, resource) = match error {
|
||||
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
|
||||
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
|
||||
language::OutlineResource::SourceBytes => Resource::SourceBytes,
|
||||
language::OutlineResource::Tokens => Resource::Tokens,
|
||||
language::OutlineResource::Declarations => Resource::Records,
|
||||
}),
|
||||
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
|
||||
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
|
||||
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
|
||||
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
|
||||
},
|
||||
};
|
||||
let unit = match resource {
|
||||
Resource::Files => "loaded files",
|
||||
Resource::SourceBytes => "source bytes",
|
||||
Resource::PathBytes => "bytes in file paths",
|
||||
Resource::Tokens => "source tokens",
|
||||
Resource::Records => "declarations and references",
|
||||
};
|
||||
format!(
|
||||
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
|
||||
)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn source_symbol_context(&self) -> nav::SymbolContext {
|
||||
nav::SymbolContext {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
|
||||
Message::SourceSymbols(self.source_symbol_context(), event)
|
||||
}
|
||||
|
||||
pub fn source_find_close_message(&self) -> Message {
|
||||
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
|
||||
}
|
||||
pub fn source_key(&self) -> query::SnapshotKey {
|
||||
query::SnapshotKey {
|
||||
workspace: self.source_workspace.session(),
|
||||
revision: self.source_workspace.revision(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
|
||||
let files: Vec<_> = self
|
||||
.source_workspace
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, buffer)| query::DraftFile {
|
||||
path,
|
||||
text: &buffer.text,
|
||||
})
|
||||
.collect();
|
||||
let accepted = self.document.bundle();
|
||||
let validated: Vec<_> = accepted
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, text)| query::DraftFile { path, text })
|
||||
.collect();
|
||||
use_input(query::Input {
|
||||
key: self.source_key(),
|
||||
entry: &self.source_workspace.entry,
|
||||
files: &files,
|
||||
applied: self.source_workspace.applied,
|
||||
validation: self
|
||||
.source_workspace
|
||||
.applied
|
||||
.then_some(query::ValidatedSources {
|
||||
entry: &accepted.entry,
|
||||
files: &validated,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
|
||||
snapshot.key() == self.source_key()
|
||||
&& self.with_source_input(|input| snapshot.matches(input))
|
||||
}
|
||||
|
||||
pub fn source_navigation_available(&self) -> bool {
|
||||
self.page == Page::Text
|
||||
&& !self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.workflow.is_none()
|
||||
&& !self.hosted.visible
|
||||
&& self.model_menu.is_none()
|
||||
&& self.diagram_usages.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
|
||||
/// Mounted widget IDs change when another interaction supersedes a reveal.
|
||||
/// A queued focus operation therefore cannot find a subsequently reused editor.
|
||||
pub fn source_editor_id(&self) -> iced::widget::Id {
|
||||
iced::widget::Id::from(format!(
|
||||
"source-editor-{}-{}-{}",
|
||||
self.source_workspace.session(),
|
||||
self.source_workspace.active,
|
||||
self.source_symbols.interaction
|
||||
))
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
.as_ref()
|
||||
.map(|reveal| reveal.ticket)
|
||||
}
|
||||
|
||||
pub(super) fn source_interaction(&mut self) {
|
||||
self.source_symbols.interaction = Id::new_v4();
|
||||
self.source_symbols.request = None;
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_jump = None;
|
||||
}
|
||||
|
||||
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = select_line;
|
||||
self.source_symbols.reveal = Some(nav::Reveal {
|
||||
ticket: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
snapshot: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn restore_source_editor_focus(&mut self) {
|
||||
let cursor = self.source_editor.cursor();
|
||||
if self.source_navigation_available()
|
||||
&& nav::exact_cursor(self.source_workspace.text(), cursor)
|
||||
{
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
|
||||
let Some(reveal) = &self.source_symbols.reveal else {
|
||||
return Task::none();
|
||||
};
|
||||
if Some(reveal.ticket) != ticket
|
||||
|| reveal.interaction != self.source_symbols.interaction
|
||||
|| !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
reveal.raw_interaction,
|
||||
)
|
||||
|| reveal.snapshot != self.source_key()
|
||||
|| reveal.path != self.source_workspace.active
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let raw_interaction = reveal.raw_interaction;
|
||||
self.source_symbols.reveal = None;
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
nav::SymbolEvent::DiagramUsages => {
|
||||
if self.source_dirty() || !self.source_workspace.applied {
|
||||
self.source_symbols.open = true;
|
||||
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let task = self.start_source_query(query::Action::Definition);
|
||||
self.source_diagram_job = self
|
||||
.source_symbols
|
||||
.request
|
||||
.as_ref()
|
||||
.map(|request| request.job);
|
||||
return task;
|
||||
}
|
||||
nav::SymbolEvent::Close => {
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
}
|
||||
nav::SymbolEvent::Back => {
|
||||
self.source_symbol_back();
|
||||
// A successful Back needs no empty results panel. Retain an
|
||||
// already open panel, or reveal an actionable return notice.
|
||||
self.source_symbols.open |= self.source_symbols.notice.is_some();
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if results.stamp != stamp
|
||||
|| stamp.snapshot != self.source_key()
|
||||
|| self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_none_or(|cache| !self.source_snapshot_matches(cache))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("Source changed. Run navigation again for current locations.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let destination = if reference {
|
||||
results
|
||||
.references
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
} else {
|
||||
results
|
||||
.definitions
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
};
|
||||
if let Some((path, start, end)) = destination {
|
||||
self.source_symbol_jump(path, start, end);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.source_symbols.open = true;
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
) {
|
||||
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_symbols.in_flight.is_some() {
|
||||
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let cursor = self.source_editor.cursor();
|
||||
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
|
||||
self.source_symbols.notice =
|
||||
Some("Place the cursor on an alias or its @ reference.".into());
|
||||
return Task::none();
|
||||
};
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
}
|
||||
};
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: self.source_key(),
|
||||
serial: self.source_symbols.serial,
|
||||
},
|
||||
path: self.source_workspace.active.clone(),
|
||||
offset,
|
||||
cursor,
|
||||
action,
|
||||
};
|
||||
let cache = self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.filter(|cache| self.source_snapshot_matches(cache))
|
||||
.cloned();
|
||||
let input = if cache.is_none() {
|
||||
match self
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
{
|
||||
Ok(input) => Some(input),
|
||||
Err(error) => {
|
||||
self.source_symbols.notice = Some(admission_notice(error));
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let snapshot = match cache {
|
||||
Some(cache) => cache,
|
||||
None => Arc::new(
|
||||
input
|
||||
.expect("admitted input for uncached request")
|
||||
.capture()
|
||||
.map_err(admission_notice)?,
|
||||
),
|
||||
};
|
||||
let results = snapshot
|
||||
.query(query::Query {
|
||||
stamp: worker_request.stamp,
|
||||
path: &worker_request.path,
|
||||
byte_offset: worker_request.offset,
|
||||
action: worker_request.action,
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
|
||||
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
|
||||
})?;
|
||||
Ok((snapshot, Arc::new(results)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
let request = completion.request;
|
||||
if self.source_symbols.in_flight != Some(request.job) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_symbols.in_flight = None;
|
||||
let diagram_query = self.source_diagram_job == Some(request.job);
|
||||
if diagram_query {
|
||||
self.source_diagram_job = None;
|
||||
}
|
||||
let wanted = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current.job == request.job)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
request.raw_interaction,
|
||||
)
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
if !wanted {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
if diagram_query {
|
||||
if results.disposition == query::Disposition::Direct
|
||||
&& results.scope.validated
|
||||
&& results.omitted_definitions == 0
|
||||
&& let [row] = results.definitions.as_slice()
|
||||
&& let query::Identity::Unique(identity) = row.declaration.identity
|
||||
&& row.membership == query::Membership::Included
|
||||
&& row.evidence == query::Evidence::Validated
|
||||
&& self.project().contains(identity.id())
|
||||
&& crate::diagram_usage::alias(self.project(), identity.id())
|
||||
== row.declaration.alias
|
||||
{
|
||||
self.source_symbols.open = false;
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
|
||||
crate::diagram_usage::Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject: identity.id(),
|
||||
},
|
||||
));
|
||||
}
|
||||
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
|
||||
}
|
||||
Err(error) => self.source_symbols.notice = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
|
||||
let cursor = text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line: start.line,
|
||||
column: start.byte_column,
|
||||
},
|
||||
selection: Some(text_editor::Position {
|
||||
line: end.line,
|
||||
column: end.byte_column,
|
||||
}),
|
||||
};
|
||||
if self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(path)
|
||||
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("That source location is no longer available. Run navigation again.".into());
|
||||
return;
|
||||
}
|
||||
if let Some(buffer) = self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&self.source_workspace.active)
|
||||
{
|
||||
let origin = nav::HistoryEntry {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
generation: buffer.generation(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
};
|
||||
self.source_symbols
|
||||
.back
|
||||
.retain(|entry| entry.session == origin.session);
|
||||
if self.source_symbols.back.len() == 64 {
|
||||
self.source_symbols.back.remove(0);
|
||||
}
|
||||
self.source_symbols.back.push(origin);
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(path, self.source_editor.cursor());
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
|
||||
fn source_symbol_back(&mut self) {
|
||||
while let Some(origin) = self.source_symbols.back.pop() {
|
||||
if origin.session != self.source_workspace.session() {
|
||||
continue;
|
||||
}
|
||||
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
|
||||
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
|
||||
return;
|
||||
};
|
||||
let unchanged = origin.generation == buffer.generation();
|
||||
let cursor = if unchanged {
|
||||
origin.cursor
|
||||
} else if origin.path == self.source_workspace.active {
|
||||
self.source_editor.cursor()
|
||||
} else {
|
||||
buffer.cursor
|
||||
};
|
||||
if !nav::exact_cursor(&buffer.text, cursor) {
|
||||
self.source_symbols.notice = Some(
|
||||
"The previous location changed. Open the file to choose a current position."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(&origin.path, self.source_editor.cursor());
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
self.source_symbols.notice =
|
||||
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
|
||||
return;
|
||||
}
|
||||
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
+291
-537
File diff suppressed because it is too large
Load Diff
@@ -1,244 +0,0 @@
|
||||
//! Shared presentation and input contract; contains no model commands.
|
||||
use crate::app::{App, Message};
|
||||
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
|
||||
use iced::{Element, Length::Fill, Point, Theme, Vector};
|
||||
use oa_core::Id;
|
||||
mod control;
|
||||
mod focus;
|
||||
mod viewport;
|
||||
pub use control::Control;
|
||||
pub use viewport::Viewport;
|
||||
|
||||
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
!app.busy
|
||||
&& !app.hosted.visible
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
&& app.pending.is_none()
|
||||
&& app.model_menu.is_none()
|
||||
&& app.diagram_usages.is_none()
|
||||
&& !app.reference_input_active()
|
||||
}
|
||||
|
||||
pub fn controls<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
scale: f32,
|
||||
details: Message,
|
||||
focus: Option<Message>,
|
||||
zoom: impl Fn(Option<f32>) -> Message,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
|
||||
let message = message.map(|message| intent(scope, name, message));
|
||||
Control::new(
|
||||
button(text(label).size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
name,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
)
|
||||
};
|
||||
row![
|
||||
control("Details", "Details", Some(details)),
|
||||
tooltip(control("Canvas", "Canvas", focus),
|
||||
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
impl Scope {
|
||||
pub(crate) fn current(app: &App) -> Option<Self> {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
} else {
|
||||
let context = app.topology_context()?;
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn input_owner(app: &App) -> Option<Self> {
|
||||
if keyboard_enabled(app)
|
||||
&& !app.behavior.has_pending()
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
{
|
||||
Self::current(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Only a live presentation intent may carry a toolbar action across its own pan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub scope: Scope,
|
||||
pub name: &'static str,
|
||||
pub message: Box<Message>,
|
||||
}
|
||||
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
|
||||
Message::BehaviorControl(Intent {
|
||||
scope,
|
||||
name,
|
||||
message: Box::new(message),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Event {
|
||||
CancelGesture,
|
||||
Pan(Vector),
|
||||
Zoom { factor: f32, at: Point, base: f32 },
|
||||
Focus,
|
||||
Traverse(bool),
|
||||
ControlFocus(&'static str),
|
||||
ToggleFocus,
|
||||
Leave,
|
||||
}
|
||||
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
if next.x.is_finite() && next.y.is_finite() && next != *pan {
|
||||
*pan = next;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Event::Zoom { factor, at, base }
|
||||
if factor.is_finite()
|
||||
&& factor > 0.
|
||||
&& base.is_finite()
|
||||
&& base > 0.
|
||||
&& at.x.is_finite()
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
let anchor = Vector::new(at.x, at.y);
|
||||
let next_pan = anchor - (anchor - *pan) * (next / old);
|
||||
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
*pan = next_pan;
|
||||
*zoom = Some(next);
|
||||
*fit = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
|
||||
pub fn palette<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
|
||||
) -> Element<'a, Message> {
|
||||
let mut tools = column![].spacing(3);
|
||||
for (glyph, label, selected, message) in entries {
|
||||
let message = message.map(|message| intent(scope, glyph, message));
|
||||
tools = tools.push(tooltip(
|
||||
container(Control::new(
|
||||
button(text(glyph).size(12))
|
||||
.width(34)
|
||||
.height(30)
|
||||
.padding(3)
|
||||
.style(if selected {
|
||||
button::primary
|
||||
} else {
|
||||
crate::style::nav
|
||||
})
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
glyph,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
))
|
||||
.id(format!("behavior-tool:{glyph}")),
|
||||
container(text(label).size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::card),
|
||||
tooltip::Position::Left,
|
||||
));
|
||||
}
|
||||
container(scrollable(tools).id(scope.control_id("palette-scroll")))
|
||||
.width(40)
|
||||
.height(Fill)
|
||||
.padding(3)
|
||||
.style(crate::style::panel)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Header and palette are permanent. Details/editors overlay the same viewport.
|
||||
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: Element<'a, Message, Theme, R>,
|
||||
palette: Element<'a, Message, Theme, R>,
|
||||
canvas: Element<'a, Message, Theme, R>,
|
||||
editor: Option<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
let mut body = stack![canvas];
|
||||
if let Some(editor) = editor {
|
||||
body = body.push(
|
||||
container(opaque(
|
||||
container(editor)
|
||||
.width(300)
|
||||
.height(Fill)
|
||||
.style(crate::style::card),
|
||||
))
|
||||
.align_right(Fill)
|
||||
.height(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
column![header, row![body, palette].height(Fill)]
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -1,256 +0,0 @@
|
||||
//! A real focusable keyboard target around the existing styled pointer button.
|
||||
use super::{Event as CameraEvent, Scope, focus::Member};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
identity: Option<iced::advanced::widget::Id>,
|
||||
focused: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
}
|
||||
}
|
||||
pub struct Control<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
name,
|
||||
enabled: enabled && activate.is_some(),
|
||||
activate,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
identity: Some(self.scope.control_id(self.name)),
|
||||
focused: false,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let identity = self.scope.control_id(self.name);
|
||||
if state.identity.as_ref() != Some(&identity) || !self.enabled {
|
||||
*state = Input {
|
||||
identity: Some(identity),
|
||||
focused: false,
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let id = self.scope.control_id(self.name);
|
||||
operation.container(Some(&id), layout.bounds());
|
||||
if self.enabled {
|
||||
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if self.enabled {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers,
|
||||
repeat,
|
||||
..
|
||||
}) = event
|
||||
&& state.focused
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
let action = match key.as_ref() {
|
||||
Key::Named(Named::Tab) => {
|
||||
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
|
||||
}
|
||||
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
|
||||
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
|
||||
Some((self.publish)(CameraEvent::ToggleFocus))
|
||||
}
|
||||
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
|
||||
self.activate.clone()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
shell.publish(action);
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && !layout
|
||||
.bounds()
|
||||
.intersection(viewport)
|
||||
.is_some_and(|bounds| cursor.is_over(bounds))
|
||||
{
|
||||
state.focused = false;
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.focused = false;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
if self.enabled && tree.state.downcast_ref::<Input>().focused {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::INK,
|
||||
width: 2.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(control: Control<'a, Message, R>) -> Self {
|
||||
Element::new(control)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
|
||||
use super::{Event, Scope};
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
|
||||
pub(super) struct Member(pub Scope);
|
||||
|
||||
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
scope,
|
||||
valid: false,
|
||||
event,
|
||||
members: vec![],
|
||||
focused: None,
|
||||
palette: None,
|
||||
})
|
||||
}
|
||||
struct Collect {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
event: Event,
|
||||
members: Vec<(Id, Rectangle)>,
|
||||
focused: Option<usize>,
|
||||
palette: Option<(Rectangle, Rectangle)>,
|
||||
}
|
||||
impl<T: Send + 'static> Operation<T> for Collect {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
if self.valid
|
||||
&& value
|
||||
.downcast_ref::<Member>()
|
||||
.is_some_and(|m| m.0 == self.scope)
|
||||
&& let Some(id) = id
|
||||
{
|
||||
self.members.push((id.clone(), bounds));
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
|
||||
if value.is_focused() {
|
||||
self.focused = self
|
||||
.members
|
||||
.iter()
|
||||
.position(|(member, _)| Some(member) == id)
|
||||
.or(self.focused);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.scope.control_id("palette-scroll")) {
|
||||
self.palette = Some((bounds, content));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<T> {
|
||||
let canvas = self.scope.control_id("viewport");
|
||||
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
|
||||
// owners expose no members; a delayed request cannot clear their focus.
|
||||
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let target = match self.event {
|
||||
Event::Focus => Some(canvas),
|
||||
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
|
||||
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
|
||||
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
|
||||
Event::Traverse(back) => {
|
||||
let Some(current) = self.focused else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let next = if back {
|
||||
(current + self.members.len() - 1) % self.members.len()
|
||||
} else {
|
||||
(current + 1) % self.members.len()
|
||||
};
|
||||
Some(self.members[next].0.clone())
|
||||
}
|
||||
_ => return operation::Outcome::None,
|
||||
};
|
||||
if target
|
||||
.as_ref()
|
||||
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
|
||||
{
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let reveal = target.as_ref().and_then(|target| {
|
||||
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
|
||||
let (bounds, content) = self.palette?;
|
||||
// Only the palette has member controls inside this scrollable.
|
||||
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
|
||||
(field.center_y() - bounds.y - bounds.height / 2.)
|
||||
.clamp(0., (content.height - bounds.height).max(0.))
|
||||
})
|
||||
});
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope,
|
||||
valid: false,
|
||||
target,
|
||||
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
|
||||
palette: self.scope.control_id("palette-scroll"),
|
||||
reveal,
|
||||
}))
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
target: Option<Id>,
|
||||
members: Vec<Id>,
|
||||
palette: Id,
|
||||
reveal: Option<f32>,
|
||||
}
|
||||
impl<T> Operation<T> for Apply {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if !self.valid {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| Some(target) == id)
|
||||
{
|
||||
state.focus();
|
||||
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& id == Some(&self.palette)
|
||||
&& let Some(y) = self.reveal
|
||||
{
|
||||
state.scroll_to(operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(y),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every Collect/Apply pass observes current ownership before touching any field.
|
||||
struct Owner(Option<Scope>);
|
||||
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
owner: Option<Scope>,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Scope>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
owner,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &InputEvent,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::futures::{
|
||||
StreamExt,
|
||||
stream::{BoxStream, SelectAll},
|
||||
};
|
||||
use iced::{Rectangle, Size, mouse};
|
||||
use iced_runtime::{
|
||||
Action,
|
||||
user_interface::{Cache, UserInterface},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
|
||||
|
||||
// iced_winit drains messages while collecting immediately ready actions. A
|
||||
// pending Task stream survives until its widget operation sends its result.
|
||||
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
|
||||
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
loop {
|
||||
match stream.poll_next_unpin(&mut context) {
|
||||
std::task::Poll::Ready(Some(action)) => ready.push(action),
|
||||
std::task::Poll::Ready(None) => break,
|
||||
std::task::Poll::Pending => {
|
||||
pending.push(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Geometry {
|
||||
pub containers: HashMap<widget::Id, Rectangle>,
|
||||
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
|
||||
pub text_inputs: Vec<Rectangle>,
|
||||
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
self.focused.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
fn text_input(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: &mut dyn widget::operation::TextInput,
|
||||
) {
|
||||
self.text_inputs.push(bounds);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if let Some(id) = id {
|
||||
self.containers.insert(id.clone(), bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.scrolls.insert(id.clone(), (bounds, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Geometry {
|
||||
pub fn bounds(&self, id: &str) -> Rectangle {
|
||||
self.containers[&widget::Id::from(id.to_owned())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the actual App root, production widgets and renderer. Events are
|
||||
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
|
||||
/// This deliberately does not seed any private canvas gesture state.
|
||||
pub(crate) struct Mounted {
|
||||
pub renderer: iced::Renderer,
|
||||
cache: Cache,
|
||||
pub operations: usize,
|
||||
}
|
||||
impl Mounted {
|
||||
pub async fn unshown() -> Self {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
|
||||
.await
|
||||
.expect("production mounted tests require the supported WGPU headless renderer");
|
||||
Self {
|
||||
renderer,
|
||||
cache: Cache::new(),
|
||||
operations: 0,
|
||||
}
|
||||
}
|
||||
pub async fn new(app: &mut App) -> Self {
|
||||
let mut mounted = Self::unshown().await;
|
||||
mounted.settle(app).await;
|
||||
mounted
|
||||
}
|
||||
pub fn geometry(&mut self, app: &App) -> Geometry {
|
||||
let mut geometry = Geometry::default();
|
||||
self.operate(app, &mut geometry);
|
||||
geometry
|
||||
}
|
||||
/// One operation pass, including every nested overlay, via installed Iced.
|
||||
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
ui.operate(&self.renderer, operation);
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
pub async fn settle(&mut self, app: &mut App) {
|
||||
for _ in 0..3 {
|
||||
self.batch(
|
||||
app,
|
||||
&[iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
pub async fn batch(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let messages = self.input(app, events, cursor);
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
for message in messages.iter().cloned() {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
self.drain(app, ready, pending).await;
|
||||
messages
|
||||
}
|
||||
pub fn input(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let mut messages = vec![];
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
messages
|
||||
}
|
||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
enqueue(task, &mut ready, &mut pending);
|
||||
self.drain(app, ready, pending).await;
|
||||
}
|
||||
async fn drain(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
mut ready: Vec<Action<Message>>,
|
||||
mut pending: Pending,
|
||||
) {
|
||||
loop {
|
||||
let mut outputs = vec![];
|
||||
if !ready.is_empty() {
|
||||
// Build only after all App messages in this cycle have run.
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
for action in ready.drain(..) {
|
||||
match action {
|
||||
Action::Output(message) => outputs.push(message),
|
||||
Action::Widget(mut operation) => {
|
||||
self.operations += 1;
|
||||
loop {
|
||||
ui.operate(&self.renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(next) => operation = next,
|
||||
widget::operation::Outcome::None
|
||||
| widget::operation::Outcome::Some(()) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
action => {
|
||||
panic!("Unexpected native action in mounted App harness: {action:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
for message in outputs {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
if !ready.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(action) = pending.next().await else {
|
||||
break;
|
||||
};
|
||||
ready.push(action);
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
|
||||
ready.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
// update initializes the nested overlay layout used by draw.
|
||||
let mut messages = vec![];
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
mouse::Cursor::Unavailable,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"An empty draw preparation must not drop App messages"
|
||||
);
|
||||
ui.draw(
|
||||
&mut self.renderer,
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: crate::style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
Headless::screenshot(
|
||||
&mut self.renderer,
|
||||
Size::new(app.window_size.width as u32, app.window_size.height as u32),
|
||||
1.,
|
||||
crate::style::BG,
|
||||
)
|
||||
}
|
||||
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
|
||||
let mut operation = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::from(id.to_owned()),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
);
|
||||
self.operate(app, &mut operation);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard::{self, Key};
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(named),
|
||||
modified_key: Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Unidentified(
|
||||
keyboard::key::NativeCode::Unidentified,
|
||||
),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
|
||||
for scale in [0.05, 0.5, 1., 2.] {
|
||||
let mut pan = Vector::new(-130., 75.);
|
||||
let mut zoom = Some(scale);
|
||||
let mut fit = false;
|
||||
let at = Point::new(171., 93.);
|
||||
let world = (at - Point::ORIGIN - pan) / scale;
|
||||
change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fit,
|
||||
Event::Zoom {
|
||||
factor: 1.25,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
);
|
||||
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
|
||||
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
|
||||
let before = (pan, zoom, fit);
|
||||
for input in [
|
||||
Event::Pan(Vector::new(f32::INFINITY, 0.)),
|
||||
Event::Zoom {
|
||||
factor: f32::NAN,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
Event::Zoom {
|
||||
factor: 0.,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
] {
|
||||
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
|
||||
assert_eq!((pan, zoom, fit), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
epoch: 0,
|
||||
};
|
||||
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
|
||||
Space::new().width(Fill).height(Fill),
|
||||
scope,
|
||||
1.,
|
||||
true,
|
||||
|e| e,
|
||||
)
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
|
||||
)
|
||||
.move_to(Point::new(30., 80.));
|
||||
let start = Point::new(90., 160.);
|
||||
let middle = Point::new(125., 175.);
|
||||
let end = Point::new(145., 185.);
|
||||
let mut messages = vec![];
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: middle },
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&iced::Event::Mouse(event),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Available(end),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(Size::new(700., 600.)),
|
||||
);
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
|
||||
let total = messages
|
||||
.iter()
|
||||
.fold(Vector::ZERO, |total, event| match event {
|
||||
Event::Pan(delta) => total + *delta,
|
||||
_ => total,
|
||||
});
|
||||
assert_eq!(total, end - start);
|
||||
}
|
||||
|
||||
pub(crate) fn scope(app: &App) -> Scope {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
} else {
|
||||
let context = app.topology_context().expect("mounted behavior owner");
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
|
||||
let id = scope(app).control_id(name);
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
|
||||
}
|
||||
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
|
||||
for _ in 0..32 {
|
||||
if focused(mounted, app, name) {
|
||||
return;
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
panic!("Keyboard traversal did not reach {name}");
|
||||
}
|
||||
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
|
||||
messages.iter().find(|m| matches!(m,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
|
||||
)).expect("actual nonzero camera delta from a mounted gesture").clone()
|
||||
}
|
||||
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
pub(crate) async fn release_middle(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
point: Point,
|
||||
) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
|
||||
let width = size.width as usize;
|
||||
assert_eq!(before.len(), width * size.height as usize * 4);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let mut changed = 0;
|
||||
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
|
||||
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
|
||||
if x as f32 - bounds.x > 2.
|
||||
&& bounds.x + bounds.width - x as f32 > 2.
|
||||
&& y as f32 - bounds.y > 2.
|
||||
&& bounds.y + bounds.height - y as f32 > 2.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (y * width + x) * 4;
|
||||
if before[start..start + 3] != after[start..start + 3] {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
changed >= bounds.width.min(bounds.height) as usize,
|
||||
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn character(value: &str) -> iced::Event {
|
||||
let mut event = key(iced::keyboard::key::Named::Space);
|
||||
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
text,
|
||||
..
|
||||
}) = &mut event
|
||||
{
|
||||
*key = iced::keyboard::Key::Character(value.into());
|
||||
*modified_key = key.clone();
|
||||
*text = Some(value.into());
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
/// Inspect the action without stripping the lifetime from retained packets.
|
||||
pub(crate) fn intent_action(message: &Message) -> &Message {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => &intent.message,
|
||||
message => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
|
||||
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
|
||||
pub(crate) async fn retained_focus_apply(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
) -> Box<dyn widget::Operation> {
|
||||
let entry = mounted.input(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
entry.len(),
|
||||
1,
|
||||
"actual initial F6 must emit one canvas focus request"
|
||||
);
|
||||
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
|
||||
.expect("F6 returns a widget Task");
|
||||
let Some(Action::Widget(mut operation)) = stream.next().await else {
|
||||
panic!("F6 first action must be Collect");
|
||||
};
|
||||
mounted.operate(app, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(apply) => apply,
|
||||
_ => panic!("Live Collect must produce its guarded Apply pass"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
|
||||
use iced::keyboard::key::Named;
|
||||
let geometry = mounted.geometry(app);
|
||||
let last_bounds = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert_eq!(offset.y, 0.);
|
||||
assert!(
|
||||
last_bounds.y >= rail.y + rail.height,
|
||||
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
|
||||
);
|
||||
mounted
|
||||
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(mounted, app, last).await;
|
||||
let geometry = mounted.geometry(app);
|
||||
let mut visible = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
|
||||
visible.x -= offset.x;
|
||||
visible.y -= offset.y;
|
||||
assert!(
|
||||
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
|
||||
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
|
||||
);
|
||||
let messages = mounted
|
||||
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
|
||||
),
|
||||
"the revealed last tool is keyboard activated"
|
||||
);
|
||||
assert!(focused(mounted, app, last));
|
||||
let on = mounted.pixels(app);
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let off = mounted.pixels(app);
|
||||
assert_drawn_focus(&off, &on, app.window_size, visible);
|
||||
}
|
||||
|
||||
pub(crate) async fn retained_toolbar_boundaries(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
tool: &'static str,
|
||||
) {
|
||||
use iced::keyboard::key::Named;
|
||||
for boundary in ["Chooser", "Dirty", "Picker"] {
|
||||
let mut packets = vec![];
|
||||
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
|
||||
packets.push(capture_toolbar(mounted, app, name));
|
||||
}
|
||||
let lifetime = app.camera_epoch;
|
||||
let epoch = app.canvas_epoch;
|
||||
let process = app.behavior.topology.camera_boundary();
|
||||
let scenario = app.behavior.scenario.camera_boundary();
|
||||
match boundary {
|
||||
"Chooser" => {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let id = crate::forms::reference_chooser::trigger::field_id(
|
||||
draft.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(field, _, _)| field.as_ref() == Some(&id))
|
||||
.expect("real Inspector parent reference trigger")
|
||||
.1;
|
||||
let request = draft.reference_open(app.project(), "/parent", bounds);
|
||||
let task = app.update(Message::FieldReferenceOpen(request));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.reference_input_active());
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.reference_input_active());
|
||||
}
|
||||
"Dirty" => {
|
||||
let task = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unsaved camera boundary".into(),
|
||||
));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.draft_dirty());
|
||||
// Every retained packet is also rejected while the owner is dirty.
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert!(app.draft_dirty());
|
||||
let task = app.update(Message::RevertProperties);
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
"Picker" => {
|
||||
// Public transitions, with cancellation injected at the external
|
||||
// picker boundary. Never poll the native dialog future here.
|
||||
let picker = app.update(Message::Save(true));
|
||||
if cfg!(target_os = "linux") {
|
||||
assert!(app.file_prompt.is_some());
|
||||
mounted.perform(app, picker).await;
|
||||
let task = app.update(Message::CancelFile);
|
||||
mounted.perform(app, task).await;
|
||||
} else {
|
||||
assert!(app.busy, "Save As enters the real native-picker busy state");
|
||||
drop(picker);
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
let task = app.update(Message::SavePath(None));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_ne!(
|
||||
app.camera_epoch, lifetime,
|
||||
"{boundary} changes the presentation lifetime"
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"{boundary} cycle leaves semantic context unchanged"
|
||||
);
|
||||
let focus_before = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>();
|
||||
let current_lifetime = app.camera_epoch;
|
||||
for packet in packets {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert_eq!(
|
||||
app.camera_epoch, current_lifetime,
|
||||
"{boundary} retained intents cannot start a new lifetime"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.topology.camera_boundary() == process,
|
||||
"{boundary} retained toolbar packet changed process presentation"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.scenario.camera_boundary() == scenario,
|
||||
"{boundary} retained toolbar packet changed scenario presentation"
|
||||
);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>(),
|
||||
focus_before,
|
||||
"{boundary} retained toolbar packet stole field focus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
|
||||
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
|
||||
mounted
|
||||
.input(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
|
||||
.expect("actual production toolbar packet")
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
use super::{Event as CameraEvent, Scope};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
|
||||
use iced::{keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
scope: Option<Scope>,
|
||||
focused: bool,
|
||||
pointer: Option<Point>,
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pan = None;
|
||||
self.space = false;
|
||||
}
|
||||
}
|
||||
pub struct Viewport<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
blank_pan: bool,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
blank_pan: false,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
pub fn pan_background(mut self, enabled: bool) -> Self {
|
||||
self.blank_pan = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
scope: Some(self.scope),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if state.scope.is_none_or(|s| {
|
||||
(s.document, s.owner, s.revision)
|
||||
!= (self.scope.document, self.scope.owner, self.scope.revision)
|
||||
}) {
|
||||
*state = Input::default();
|
||||
}
|
||||
if state
|
||||
.scope
|
||||
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|
||||
|| !self.keyboard
|
||||
{
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
state.scope = Some(self.scope);
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fill)
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if self.keyboard {
|
||||
let id = self.scope.control_id("viewport");
|
||||
operation.custom(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
&mut super::focus::Member(self.scope),
|
||||
);
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
|
||||
state.bounds = Some(layout.bounds());
|
||||
if changed_bounds || !self.keyboard {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
let pointer = match event {
|
||||
Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_)
|
||||
| mouse::Event::ButtonReleased(_)
|
||||
| mouse::Event::WheelScrolled { .. },
|
||||
) => state.pointer.or_else(|| cursor.position()),
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
_ => cursor.position().or(state.pointer),
|
||||
};
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
|
||||
&& cursor.position().is_some();
|
||||
if !self.keyboard {
|
||||
state.space = false;
|
||||
}
|
||||
let mut cancel = changed_bounds;
|
||||
let mut camera = None;
|
||||
let mut capture = false;
|
||||
match event {
|
||||
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focused = false;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pan = None;
|
||||
state.pointer = None;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
cancel = true;
|
||||
}
|
||||
state.focused = inside && self.keyboard;
|
||||
if inside
|
||||
&& self.keyboard
|
||||
&& (*button == mouse::Button::Middle
|
||||
|| (*button == mouse::Button::Left && state.space))
|
||||
{
|
||||
state.pan = pointer.map(|p| (*button, p));
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
if let Some((button, previous)) = state.pan {
|
||||
state.pan = Some((button, *position));
|
||||
camera = Some(CameraEvent::Pan(*position - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
if let Some((held, previous)) = state.pan
|
||||
&& held == *button
|
||||
{
|
||||
state.pan = None;
|
||||
camera = pointer
|
||||
.filter(|_| inside)
|
||||
.map(|p| CameraEvent::Pan(p - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
|
||||
state.pan = None;
|
||||
cancel = true;
|
||||
let y = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => *y,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
|
||||
};
|
||||
if y != 0. {
|
||||
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
|
||||
camera = Some(CameraEvent::Zoom {
|
||||
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
|
||||
at,
|
||||
base: self.scale,
|
||||
});
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F6),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if self.keyboard && modifiers.is_empty() => {
|
||||
camera = Some(CameraEvent::ToggleFocus);
|
||||
capture = true;
|
||||
cancel = true;
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
|
||||
if state.focused && self.keyboard =>
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
match key.as_ref() {
|
||||
Key::Named(Named::Space) => {
|
||||
state.space = true;
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Tab) => {
|
||||
camera = Some(CameraEvent::Traverse(modifiers.shift()));
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Escape) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
camera = Some(CameraEvent::Leave);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(key) if modifiers.is_empty() => {
|
||||
let delta = match key {
|
||||
Named::ArrowLeft => Some(Vector::new(60., 0.)),
|
||||
Named::ArrowRight => Some(Vector::new(-60., 0.)),
|
||||
Named::ArrowUp => Some(Vector::new(0., 60.)),
|
||||
Named::ArrowDown => Some(Vector::new(0., -60.)),
|
||||
_ => None,
|
||||
};
|
||||
camera = delta.map(CameraEvent::Pan);
|
||||
capture = camera.is_some();
|
||||
cancel = capture;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
|
||||
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
|
||||
{
|
||||
state.space = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cancel {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&Event::Window(iced::window::Event::Unfocused),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
if let Some(event) = camera {
|
||||
shell.publish((self.publish)(event));
|
||||
}
|
||||
if capture {
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
|
||||
return;
|
||||
}
|
||||
let child_cursor = if inside {
|
||||
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
child_cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// A semantic hit or armed creation tool gets first refusal. In Select,
|
||||
// an otherwise unused blank press starts the same camera gesture.
|
||||
if self.blank_pan
|
||||
&& self.keyboard
|
||||
&& inside
|
||||
&& !shell.is_event_captured()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.pan = pointer.map(|p| (mouse::Button::Left, p));
|
||||
shell.publish((self.publish)(CameraEvent::CancelGesture));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
renderer.with_layer(layout.bounds(), |renderer| {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
});
|
||||
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::TEAL,
|
||||
width: 1.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if tree.state.downcast_ref::<Input>().pan.is_some() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(viewport: Viewport<'a, Message, R>) -> Self {
|
||||
Element::new(viewport)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,6 @@ impl Picker {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
@@ -98,11 +97,6 @@ struct Browser {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
|
||||
version: u32,
|
||||
document: Id,
|
||||
layer: Layer,
|
||||
@@ -131,17 +125,6 @@ pub struct Created {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.draft.is_some()
|
||||
}
|
||||
@@ -191,49 +174,6 @@ impl State {
|
||||
.command(p)
|
||||
}
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.family != Some(Family::Machine) {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.document != p.id {
|
||||
return Err("This behavior draft belongs to another document.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Machine { layer: d.layer },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.owner = Some(id);
|
||||
let preview = next
|
||||
.source
|
||||
.preview(p, next.owner)?
|
||||
.ok_or("New owner preview is missing.")?;
|
||||
if !eligible(&preview, next.layer, Picker::Owner, id) {
|
||||
return Err("This participant cannot own the machine.".into());
|
||||
}
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Cancel => {
|
||||
self.draft = None;
|
||||
@@ -244,8 +184,6 @@ impl State {
|
||||
return Err("Create or cancel the pending behavior first.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
version: 1,
|
||||
document: p.id,
|
||||
layer,
|
||||
@@ -394,7 +332,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
@@ -402,15 +340,6 @@ pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Machine { layer: self.layer },
|
||||
self.owner,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.owner)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.owner, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id {
|
||||
return Err("This draft belongs to another document.".into());
|
||||
}
|
||||
@@ -546,21 +475,6 @@ impl State {
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.draft.as_ref()?;
|
||||
if let Some(view) = d.source.view(p) {
|
||||
return Some(
|
||||
column![
|
||||
text(format!(
|
||||
"State machine name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
view.map(Event::Source)
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut fields = column![].spacing(10);
|
||||
let title = if let Some(family) = d.family {
|
||||
fields = fields.push(
|
||||
@@ -574,7 +488,7 @@ impl State {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
"Owned by the {} layer root. Involve existing work after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
@@ -617,17 +531,7 @@ impl State {
|
||||
}
|
||||
}
|
||||
Family::Machine => {
|
||||
if let Some(label) = d.source.label(d.owner) {
|
||||
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
|
||||
}
|
||||
fields = fields.push(picker_view(p, d, Picker::Owner));
|
||||
fields = fields.push(
|
||||
button("Create new owner…")
|
||||
.on_press(Event::Source(super::source_draft::Event::Open(
|
||||
d.invocation,
|
||||
)))
|
||||
.padding(7),
|
||||
);
|
||||
fields = fields.push(
|
||||
checkbox(d.first_view)
|
||||
.label("Create the first state view")
|
||||
@@ -640,9 +544,9 @@ impl State {
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Sequence => "A path through existing work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
Family::Machine => "States and modes of an existing participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
@@ -694,9 +598,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
@@ -707,17 +609,14 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
if let Some(id) = selected {
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Keep behavior field state within one editing session and reset it on replacement.
|
||||
use iced::advanced::widget::{Operation, Tree, tree};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
use oa_core::Id;
|
||||
|
||||
struct Identity(Id);
|
||||
|
||||
struct Session<'a, Message, R: renderer::Renderer> {
|
||||
identity: Id,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
identity: Id,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Session {
|
||||
identity,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Identity>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Identity(self.identity))
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let retained = tree.state.downcast_mut::<Identity>();
|
||||
if retained.0 != self.identity {
|
||||
retained.0 = self.identity;
|
||||
// Iced's keyed column preserves same-length child replacements.
|
||||
// An editing-session replacement must instead get fresh field state.
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
pub(crate) mod focus;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -100,9 +97,7 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
FormShown(Context, Id),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
@@ -130,7 +125,8 @@ pub enum CanvasEvent {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollArea {
|
||||
// The independent Details/outline scroller; viewport motion uses Camera.
|
||||
Canvas,
|
||||
// Retained navigation channel, now the independent Selection/outline shelf.
|
||||
Page,
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -139,9 +135,7 @@ impl CanvasEvent {
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
@@ -168,7 +162,6 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -204,14 +197,11 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_mount: Id,
|
||||
form_focus_pending: bool,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
details_offset: iced::Vector,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<Caller>,
|
||||
restore: Option<DetailsRestore>,
|
||||
restore: Option<ViewportRestore>,
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
const CALLER_LIMIT: usize = 32;
|
||||
@@ -221,14 +211,15 @@ pub(crate) struct Caller {
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
details_offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
struct DetailsRestore {
|
||||
struct ViewportRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
pending: [bool; 2],
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ScrollRestore {
|
||||
@@ -249,13 +240,6 @@ struct CallChoiceContext {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
@@ -288,44 +272,6 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_tool,
|
||||
self.presentation.focused,
|
||||
self.presentation.shelf,
|
||||
self.presentation.viewport,
|
||||
self.presentation.pan,
|
||||
self.presentation.zoom,
|
||||
self.presentation.fit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
returned
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
@@ -333,38 +279,16 @@ impl State {
|
||||
focused: self.presentation.focused,
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
}
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
let p = &mut self.presentation;
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
if let Some(next) = zoom {
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
@@ -393,11 +317,11 @@ impl State {
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
details_offset: self.details_offset,
|
||||
offsets: self.scroll_offsets,
|
||||
presentation: self.presentation.clone(),
|
||||
});
|
||||
self.canvas_tool = Some((target, CanvasTool::Select));
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
Ok(target)
|
||||
}
|
||||
@@ -424,30 +348,34 @@ impl State {
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.scroll_offsets = frame.offsets;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.restore = Some(DetailsRestore {
|
||||
self.restore = Some(ViewportRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
offsets: frame.offsets,
|
||||
pending: [true; 2],
|
||||
});
|
||||
}
|
||||
pub(super) fn scroll_restore(
|
||||
&self,
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
area: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.pending[index]
|
||||
})?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
offset: restore.offsets[index],
|
||||
})
|
||||
}
|
||||
pub(crate) fn restored(
|
||||
@@ -457,12 +385,22 @@ impl State {
|
||||
area: ScrollArea,
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
}) {
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let Some(restore) = self.restore.as_mut().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.token == token
|
||||
&& r.pending[index]
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
restore.pending[index] = false;
|
||||
if !restore.pending.contains(&true) {
|
||||
self.restore = None;
|
||||
}
|
||||
self.restore = None;
|
||||
self.scrolled(area, offset)
|
||||
}
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
@@ -475,57 +413,28 @@ impl State {
|
||||
|
||||
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let session = Id::new_v4();
|
||||
Ok(Self {
|
||||
draft: Some(draft),
|
||||
form_session: session,
|
||||
form_mount: session,
|
||||
form_focus_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn form_session(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|_| self.form_session)
|
||||
}
|
||||
|
||||
pub(crate) fn form_mount(&self) -> Option<Id> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.filter(|d| !d.source.is_open())
|
||||
.map(|_| self.form_mount)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
|
||||
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
|
||||
context,
|
||||
self.form_session,
|
||||
self.form_mount()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn remount_form(&mut self) {
|
||||
self.form_mount = Id::new_v4();
|
||||
self.form_focus_pending = true;
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
|
||||
if self.form_mount() == Some(mount) && self.form_focus_pending {
|
||||
self.form_focus_pending = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let changed = self.details_offset != offset;
|
||||
self.details_offset = offset;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
||||
let key = CallChoiceContext {
|
||||
document: context.document,
|
||||
@@ -543,16 +452,8 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
match command {
|
||||
Command::CreateElement(element) if element.parent == Some(parent) => {
|
||||
@@ -667,66 +568,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
self.remount_form();
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Lifeline {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: d.owner,
|
||||
viewpoint: viewpoint(p, d.owner),
|
||||
},
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
let returned = d.source.is_open() && !next.source.is_open();
|
||||
self.draft = Some(next);
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -735,13 +576,7 @@ impl State {
|
||||
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.form_session = Id::new_v4();
|
||||
self.form_mount = self.form_session;
|
||||
self.form_focus_pending = true;
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1105,14 +940,7 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
||||
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
||||
}
|
||||
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let layer = p.elements[&d.owner].layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1428,28 +1256,6 @@ impl Draft {
|
||||
e
|
||||
}
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: self.owner,
|
||||
viewpoint: viewpoint(p, self.owner),
|
||||
},
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
||||
}
|
||||
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
||||
return Err("Choose this scenario or one of its operands as destination.".into());
|
||||
}
|
||||
@@ -1460,8 +1266,6 @@ impl Draft {
|
||||
.unwrap_or(self.tool.label());
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -2053,31 +1857,20 @@ pub(crate) mod direct_tests {
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
||||
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
|
||||
assert_eq!(app.selected, c);
|
||||
@@ -2088,7 +1881,7 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(0., 115.)
|
||||
);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
@@ -2099,11 +1892,11 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Add(Tool::Reuse)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 170.)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
app.behavior.scenario.scroll_offsets[1],
|
||||
iced::Vector::new(0., 80.),
|
||||
"The caller's independent outline scroll is restored separately"
|
||||
);
|
||||
@@ -2281,24 +2074,22 @@ pub(crate) mod direct_tests {
|
||||
let before = app.document.bundle().clone();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Add(tool), true));
|
||||
scene.camera_pan = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(20., 40.),
|
||||
Size::new(scene.size.width + 46., scene.size.height + 160.),
|
||||
);
|
||||
scene.scroll_offset = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
|
||||
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
|
||||
let x = if on_lifeline {
|
||||
scene.lanes[&f.sender]
|
||||
} else {
|
||||
scene.size.width - 35.
|
||||
};
|
||||
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
|
||||
assert!(bounds.contains(point));
|
||||
let point = Point::new(bounds.x + x, bounds.y + y);
|
||||
let mut input = SequenceInput::default();
|
||||
canvas_mouse(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::CursorMoved {
|
||||
position: point - scene.scroll_offset,
|
||||
},
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
@@ -2474,10 +2265,10 @@ pub(crate) mod direct_tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
old_context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 100.),
|
||||
)));
|
||||
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
@@ -2668,29 +2459,26 @@ pub(crate) mod direct_tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
|
||||
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
|
||||
let f = fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
|
||||
)));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
|
||||
(ScrollArea::Page, iced::Vector::new(0., 40.)),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
assert!(app.busy);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
assert_ne!(
|
||||
app.scenario_context(),
|
||||
@@ -2701,14 +2489,14 @@ pub(crate) mod direct_tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
ScrollArea::Page,
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
let root = std::path::PathBuf::from("scenario-details-save-fixture");
|
||||
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
|
||||
let receipt = crate::app::FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
@@ -2729,17 +2517,12 @@ pub(crate) mod direct_tests {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
iced::Vector::new(-25., -100.)
|
||||
);
|
||||
scene.camera_pan = app.behavior.scenario.presentation.pan;
|
||||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||
let slot = scene.slots.last().unwrap().slot;
|
||||
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
|
||||
let y = bounds.y + scene.slots.last().unwrap().y;
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
|
||||
let mut input = super::super::SequenceInput::default();
|
||||
let mut published = vec![];
|
||||
for event in [
|
||||
@@ -2753,7 +2536,7 @@ pub(crate) mod direct_tests {
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(b),
|
||||
mouse::Cursor::Available(b + scene.scroll_offset),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
{
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
//! A delayed focus request must still own the live parent tree when executed.
|
||||
//! Collect first: a missing target must not unfocus unrelated fields.
|
||||
use super::Context;
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
|
||||
use oa_core::Id as ModelId;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
}
|
||||
impl Stamp {
|
||||
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
))
|
||||
}
|
||||
struct Owner(Option<Stamp>);
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
owner: Option<Stamp>,
|
||||
}
|
||||
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Stamp>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
content: content.into(),
|
||||
owner,
|
||||
})
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
op: &mut dyn Operation,
|
||||
) {
|
||||
// This is computed from the current App view, never retained widget state.
|
||||
op.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, op);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Collect {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending: Option<Scroll>,
|
||||
index: usize,
|
||||
target: Option<(Rectangle, Vec<Scroll>)>,
|
||||
}
|
||||
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
stamp,
|
||||
valid: false,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending: None,
|
||||
index: 0,
|
||||
target: None,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.scrolls.len());
|
||||
if let Some(scroll) = self.pending.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.translation = previous.0;
|
||||
self.scrolls.truncate(previous.1);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending = Some(Scroll {
|
||||
index: self.index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.index += 1;
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.valid {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let Some((mut bounds, ancestors)) = self.target.clone() else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in ancestors.iter().rev() {
|
||||
let dx = reveal(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Drawing offsets are rounded; an already-visible axis must not
|
||||
// acquire a scroll delta from a fractional capacity alone.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
stamp: self.stamp,
|
||||
valid: false,
|
||||
shifts,
|
||||
index: 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
|
||||
if start < viewport || length > size {
|
||||
start - viewport
|
||||
} else {
|
||||
(start + length - viewport - size).max(0.)
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
index: usize,
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.valid {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& let Some(delta) = self.shifts.get(&self.index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//! Navigation is derived from the same opaque-reference scene that is painted.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The renderer collapses whitespace before shortening. Require at least one
|
||||
/// actual character of this field, rather than an ellipsis at its start.
|
||||
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
|
||||
if value.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let start = normalized_prefix.len()
|
||||
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
|
||||
let painted = crate::views::short(full, 32);
|
||||
let retained = normalized
|
||||
.chars()
|
||||
.zip(painted.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(c, _)| c.len_utf8())
|
||||
.sum::<usize>();
|
||||
start < retained
|
||||
}
|
||||
|
||||
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
let mut result = Vec::new();
|
||||
let mut add = |id, bounds, role: &str| {
|
||||
// Activation bars share the occurrence identity with a labelled message;
|
||||
// they must not inflate its target count.
|
||||
if !result
|
||||
.iter()
|
||||
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
|
||||
{
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::ScenarioObject(id),
|
||||
role: role.into(),
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
};
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(element) = p.elements.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
if subject == item.id {
|
||||
// A fragment's selectable header is useful even when its frame is
|
||||
// much larger than the viewport.
|
||||
let mut bounds = item.rect;
|
||||
if item.marker == Some(Kind::Occurrence) {
|
||||
bounds.height = bounds.height.min(38.);
|
||||
}
|
||||
add(item.id, bounds, "scenario object");
|
||||
}
|
||||
let represented = match element.shared() {
|
||||
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
|
||||
Some(SharedBody::Occurrence { body, .. }) => match body {
|
||||
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
|
||||
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
sequence.map(|id| (id, "process reference"))
|
||||
}
|
||||
OccurrenceBody::ScenarioUse { scenario, .. } => {
|
||||
scenario.map(|id| (id, "scenario reference"))
|
||||
}
|
||||
// An incomplete message fallback box only paints the occurrence
|
||||
// name and a repair hint, not its exchange or operation.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
|
||||
let _ = id;
|
||||
add(item.id, item.rect, role);
|
||||
}
|
||||
}
|
||||
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let Some(element) = p.elements.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = element.shared()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bounds = line.label_bounds();
|
||||
if subject == line.id || *exchange == Some(subject) {
|
||||
add(line.id, bounds, "message occurrence");
|
||||
}
|
||||
// Replies derive their operation from the exact call, matching the
|
||||
// operation label formatter rather than scanning raw reference fields.
|
||||
if let Ok(operations::OperationResolution::Selected(operation)) =
|
||||
operations::resolve(p, line.id)
|
||||
&& let Some(label) = crate::data_values::operation_label(p, element)
|
||||
&& let Some(prefix) = line.label.strip_suffix(&label)
|
||||
{
|
||||
let allocation = &p.relations[&operation.allocation];
|
||||
let operation_prefix = if matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
"Reply operation from Call: "
|
||||
} else {
|
||||
"Operation: "
|
||||
};
|
||||
let fields = [
|
||||
(allocation.source(), format!("{prefix}{operation_prefix}")),
|
||||
(
|
||||
allocation.target(),
|
||||
format!(
|
||||
"{prefix}{operation_prefix}{} / ",
|
||||
p.named(allocation.source())
|
||||
),
|
||||
),
|
||||
(
|
||||
allocation.id,
|
||||
format!(
|
||||
"{prefix}{operation_prefix}{} / {} · ",
|
||||
p.named(allocation.source()),
|
||||
p.named(allocation.target())
|
||||
),
|
||||
),
|
||||
];
|
||||
for (id, prefix) in fields {
|
||||
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
|
||||
add(line.id, bounds, "operation label");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject == owner {
|
||||
result.insert(
|
||||
0,
|
||||
Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::Whole,
|
||||
role: "scenario".into(),
|
||||
bounds: Rectangle::new(Point::ORIGIN, scene.size),
|
||||
},
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
if !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let id = match target {
|
||||
Target::Whole => owner,
|
||||
Target::ScenarioObject(id) => *id,
|
||||
_ => return false,
|
||||
};
|
||||
let Some(usage) = self
|
||||
.diagram_usages(p, owner, id)
|
||||
.into_iter()
|
||||
.find(|u| &u.target == target)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// Preserve an existing mounted viewport across enter's owner reset.
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.enter(document, owner);
|
||||
self.presentation.viewport = Some(viewport);
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
self.presentation.zoom(Some(zoom));
|
||||
self.presentation.pan = pan;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,276 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let layer = p.elements[&owner].layer();
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
|
||||
state
|
||||
.diagram_usages(p, owner, subject)
|
||||
.into_iter()
|
||||
.filter_map(|u| match u.target {
|
||||
Target::ScenarioObject(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
|
||||
for (layer, work_kind, participant_kind) in [
|
||||
(Layer::Oa, Kind::Activity, Kind::Entity),
|
||||
(Layer::Sa, Kind::Function, Kind::SystemActor),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, layer, "Caller", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Scenario, layer, "Definition", None)
|
||||
.unwrap();
|
||||
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
|
||||
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
|
||||
let participant = p
|
||||
.add_element(participant_kind, layer, "Operator", None)
|
||||
.unwrap();
|
||||
let lanes = ["First role", "Second role"].map(|name| {
|
||||
let id = p
|
||||
.add_element(Kind::Lifeline, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(participant),
|
||||
};
|
||||
id
|
||||
});
|
||||
let first = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"First action",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: Some(lanes[0]),
|
||||
},
|
||||
);
|
||||
let fragment = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Decision",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = add_occurrence(
|
||||
&mut p,
|
||||
fragment,
|
||||
"When ready",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch);
|
||||
}
|
||||
let nested = add_occurrence(
|
||||
&mut p,
|
||||
branch,
|
||||
"Again",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: Some(lanes[1]),
|
||||
},
|
||||
);
|
||||
let inside = add_occurrence(
|
||||
&mut p,
|
||||
other,
|
||||
"Hidden inside definition",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(unused),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let use_a = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Use A",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(other),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let use_b = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Use B",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(other),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let before = p.clone();
|
||||
let state = State::default();
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, work),
|
||||
BTreeSet::from([first, nested])
|
||||
);
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, participant),
|
||||
BTreeSet::from(lanes)
|
||||
);
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, other),
|
||||
BTreeSet::from([use_a, use_b])
|
||||
);
|
||||
assert!(targets(&state, &p, owner, unused).is_empty());
|
||||
assert!(targets(&state, &p, owner, inside).is_empty());
|
||||
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
|
||||
assert_eq!(
|
||||
state.diagram_usages(&p, owner, owner)[0].target,
|
||||
Target::Whole
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let mut state = State::default();
|
||||
assert_eq!(
|
||||
targets(&state, &p, f.owner, f.exchange),
|
||||
BTreeSet::from([f.call, f.reply])
|
||||
);
|
||||
let before = p.clone();
|
||||
let target = Target::ScenarioObject(f.reply);
|
||||
state.viewport(Size::new(900., 500.));
|
||||
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
|
||||
let usage = state
|
||||
.diagram_usages(&p, f.owner, f.reply)
|
||||
.into_iter()
|
||||
.find(|u| u.target == target)
|
||||
.unwrap();
|
||||
let zoom = state
|
||||
.presentation
|
||||
.scale(Scene::sequence(&p, f.owner, f.reply).size);
|
||||
let centered = Point::new(
|
||||
usage.bounds.center_x() * zoom,
|
||||
usage.bounds.center_y() * zoom,
|
||||
) + state.presentation.pan;
|
||||
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
|
||||
assert!(zoom >= 0.5);
|
||||
assert_eq!(p, before);
|
||||
// Keeping an orphan semantic occurrence does not keep its painted target.
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.reply);
|
||||
let camera = state.camera_boundary();
|
||||
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
|
||||
assert!(camera == state.camera_boundary());
|
||||
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
|
||||
// A message whose endpoints are incomplete paints only its repair box.
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { receiver, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
|
||||
{
|
||||
*receiver = None;
|
||||
}
|
||||
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
|
||||
assert_eq!(
|
||||
targets(&state, &p, f.owner, f.call),
|
||||
BTreeSet::from([f.call])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
|
||||
assert!(label_field_visible(
|
||||
"1. call · X · Operation: I / O",
|
||||
"1. call · X · Operation: ",
|
||||
"I"
|
||||
));
|
||||
assert!(!label_field_visible(
|
||||
"1234567890123456789012345678901Ixyz",
|
||||
"1234567890123456789012345678901",
|
||||
"Ixyz"
|
||||
));
|
||||
assert!(!label_field_visible(
|
||||
"A very long occurrence label · Operation: I",
|
||||
"A very long occurrence label · Operation: ",
|
||||
"I"
|
||||
));
|
||||
assert!(label_field_visible(
|
||||
"call\n\t· Operation: 界",
|
||||
"call\n\t· Operation: ",
|
||||
"界"
|
||||
));
|
||||
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
|
||||
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
|
||||
let (interface, item) = (
|
||||
p.relations[&allocation].source(),
|
||||
p.relations[&allocation].target(),
|
||||
);
|
||||
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
|
||||
p.elements.get_mut(&id).unwrap().name = name.into();
|
||||
}
|
||||
p.relations.get_mut(&exchange).unwrap().name = "x".into();
|
||||
p.relations.get_mut(&allocation).unwrap().name = "A".into();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
}
|
||||
assert!(matches!(
|
||||
operations::resolve(&p, call),
|
||||
Ok(operations::OperationResolution::Selected(_))
|
||||
));
|
||||
let state = State::default();
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, interface),
|
||||
BTreeSet::from([call])
|
||||
);
|
||||
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
|
||||
assert!(targets(&state, &p, owner, allocation).is_empty());
|
||||
assert_eq!(p, before);
|
||||
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
|
||||
assert!(targets(&state, &p, owner, interface).is_empty());
|
||||
assert!(targets(&state, &p, owner, item).is_empty());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) mod render;
|
||||
|
||||
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
|
||||
pub(crate) struct Geometry<'a> {
|
||||
pub rows: &'a BTreeMap<Id, (Id, f32)>,
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
//! Scene bridge for local lifetime geometry; no authoring or model mutations.
|
||||
use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
|
||||
use crate::{behavior::Line, style, views::short};
|
||||
use iced::{
|
||||
Color, Point, Rectangle, Size,
|
||||
widget::canvas::{self, Frame, Path, Stroke, Text},
|
||||
};
|
||||
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct EventMarker {
|
||||
event: Marker,
|
||||
lifeline: Id,
|
||||
center: Point,
|
||||
capsule: Rectangle,
|
||||
name: String,
|
||||
sender_x: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub(in crate::behavior) struct Rendering {
|
||||
// Complete rendering positions, including warning-admitted unassigned lanes.
|
||||
// Scene.lanes independently retains the existing authoring eligibility rule.
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
span: Option<Span>,
|
||||
lifetimes: Vec<LaneLifetime>,
|
||||
markers: Vec<EventMarker>,
|
||||
}
|
||||
|
||||
impl Rendering {
|
||||
pub(in crate::behavior) fn new(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
rows: &BTreeMap<Id, (Id, f32)>,
|
||||
insertion_scopes: &BTreeMap<Id, (f32, f32)>,
|
||||
height: f32,
|
||||
) -> Self {
|
||||
let span = Span {
|
||||
start: 85.,
|
||||
end: height - 20.,
|
||||
};
|
||||
if !span.valid() {
|
||||
return Self::default();
|
||||
}
|
||||
// Insertion scopes deliberately start later and end earlier. Empty and
|
||||
// Create-only scenarios still have the full visible lifetime extent.
|
||||
let mut scopes = insertion_scopes.clone();
|
||||
for (scope, (start, end)) in &mut scopes {
|
||||
if *scope != owner && *start == *end && start.is_finite() {
|
||||
// An empty operand has 42 px between its insertion axis and
|
||||
// frame end. Match the existing 26 px open-activation allowance.
|
||||
*end = *start + 26.;
|
||||
}
|
||||
}
|
||||
scopes.insert(owner, (span.start, span.end));
|
||||
let lifetimes = project_lifetimes(
|
||||
project,
|
||||
owner,
|
||||
&Geometry {
|
||||
rows,
|
||||
scopes: &scopes,
|
||||
},
|
||||
);
|
||||
let lanes: BTreeMap<_, _> = lanes
|
||||
.iter()
|
||||
.filter(|(_, x)| x.is_finite())
|
||||
.map(|(id, x)| (*id, *x))
|
||||
.collect();
|
||||
let mut markers = Vec::new();
|
||||
for lane in &lifetimes {
|
||||
let Some(&x) = lanes.get(&lane.lifeline) else {
|
||||
continue;
|
||||
};
|
||||
for event in &lane.markers {
|
||||
let sender_x = project
|
||||
.elements
|
||||
.get(&event.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender: Some(sender),
|
||||
..
|
||||
},
|
||||
..
|
||||
} => lanes.get(sender).copied(),
|
||||
_ => None,
|
||||
});
|
||||
markers.push(EventMarker {
|
||||
event: *event,
|
||||
lifeline: lane.lifeline,
|
||||
center: Point::new(x, event.y),
|
||||
capsule: Rectangle::new(
|
||||
Point::new(x - 70., event.y - 14.),
|
||||
Size::new(140., 28.),
|
||||
),
|
||||
name: project.named(lane.lifeline).to_owned(),
|
||||
sender_x,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self {
|
||||
lanes,
|
||||
span: Some(span),
|
||||
lifetimes,
|
||||
markers,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
|
||||
for marker in &self.markers {
|
||||
let Some(sender) = marker.sender_x else {
|
||||
continue;
|
||||
};
|
||||
let Some(line) = lines
|
||||
.iter_mut()
|
||||
.find(|line| line.id == marker.event.occurrence)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let x = marker.center.x;
|
||||
let y = marker.center.y;
|
||||
let self_message = sender == x;
|
||||
let end_x = match marker.event.kind {
|
||||
MarkerKind::Birth if sender < x => marker.capsule.x,
|
||||
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
|
||||
MarkerKind::Death => x,
|
||||
};
|
||||
line.points = if self_message {
|
||||
// The receiver is on the recorded occurrence row, including self
|
||||
// lifecycle events. The elbow stays outside the birth capsule.
|
||||
let elbow = x + 112.;
|
||||
line.label_at = Point::new(x + 12., y - 52.);
|
||||
vec![
|
||||
Point::new(x, y - 24.),
|
||||
Point::new(elbow, y - 24.),
|
||||
Point::new(elbow, y),
|
||||
Point::new(end_x, y),
|
||||
]
|
||||
} else {
|
||||
if marker.event.kind == MarkerKind::Birth {
|
||||
// Keep the label above the capsule, including right-to-left
|
||||
// messages whose existing label starts beside the receiver.
|
||||
line.label_at.y = y - 40.;
|
||||
}
|
||||
vec![Point::new(sender, y), Point::new(end_x, y)]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn alive_spans(
|
||||
&self,
|
||||
lifeline: Id,
|
||||
scope: Id,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
) -> Vec<Span> {
|
||||
if !top.is_finite() || !bottom.is_finite() || bottom <= top {
|
||||
return vec![];
|
||||
}
|
||||
self.lifetimes
|
||||
.iter()
|
||||
.find(|lane| lane.lifeline == lifeline)
|
||||
.into_iter()
|
||||
.flat_map(|lane| &lane.intervals)
|
||||
.filter(|interval| interval.scope == scope && interval.life == Life::Alive)
|
||||
.filter_map(|interval| {
|
||||
let start = top.max(interval.span.start);
|
||||
let end = bottom.min(interval.span.end);
|
||||
(start < end).then_some(Span { start, end })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
||||
self.markers
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|marker| match marker.event.kind {
|
||||
MarkerKind::Birth => marker.capsule.contains(point),
|
||||
MarkerKind::Death => marker_distance(marker, point) <= 1.25,
|
||||
})
|
||||
.map(|marker| marker.event.occurrence)
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Exact visible shapes are tested first by Scene. A low-zoom tolerance
|
||||
// must not outrank another marker's actual capsule, a header, or a label.
|
||||
self.markers
|
||||
.iter()
|
||||
.map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
|
||||
let span = self.span?;
|
||||
if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
|
||||
return None;
|
||||
}
|
||||
self.lanes
|
||||
.iter()
|
||||
.filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
|
||||
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
|
||||
let Some(span) = self.span else {
|
||||
return;
|
||||
};
|
||||
for (&id, &x) in &self.lanes {
|
||||
frame.stroke(
|
||||
&Path::line(Point::new(x, span.start), Point::new(x, span.end)),
|
||||
Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[2., 5.],
|
||||
offset: 0,
|
||||
},
|
||||
..Stroke::default()
|
||||
.with_width(1.)
|
||||
.with_color(if id == selected {
|
||||
Color::from_rgb8(148, 190, 187)
|
||||
} else {
|
||||
Color::from_rgb8(214, 222, 222)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
for lifetime in &self.lifetimes {
|
||||
let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
|
||||
continue;
|
||||
};
|
||||
for interval in &lifetime.intervals {
|
||||
match interval.life {
|
||||
Life::Alive => frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(x, interval.span.start),
|
||||
Point::new(x, interval.span.end),
|
||||
),
|
||||
Stroke::default().with_width(2.).with_color(
|
||||
if lifetime.lifeline == selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(91, 113, 113)
|
||||
},
|
||||
),
|
||||
),
|
||||
Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
|
||||
frame.fill_text(Text {
|
||||
content: "?".into(),
|
||||
position: Point::new(x + 9., interval.span.start + 2.),
|
||||
size: 11.into(),
|
||||
color: style::MUTED,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.lanes.is_empty() {
|
||||
frame.fill_text(Text {
|
||||
content:
|
||||
"Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
|
||||
.into(),
|
||||
position: Point::new(28., span.end + 4.),
|
||||
size: 10.into(),
|
||||
color: style::MUTED,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn draw_markers(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
pan: iced::Vector,
|
||||
) {
|
||||
for marker in &self.markers {
|
||||
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
};
|
||||
match marker.event.kind {
|
||||
MarkerKind::Birth => {
|
||||
let path = Path::rounded_rectangle(
|
||||
marker.capsule.position(),
|
||||
marker.capsule.size(),
|
||||
6.into(),
|
||||
);
|
||||
frame.fill(&path, Color::from_rgb8(243, 248, 248));
|
||||
frame.stroke(
|
||||
&path,
|
||||
Stroke::default().with_color(color).with_width(
|
||||
if selected == marker.event.occurrence {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
},
|
||||
),
|
||||
);
|
||||
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
|
||||
// WGPU clipped frames start with an identity transform;
|
||||
// neither the parent's pan/scale nor its clip is inherited.
|
||||
frame.with_clip(clip, |label| {
|
||||
label.translate(pan);
|
||||
label.scale(scale);
|
||||
label.fill_text(Text {
|
||||
content: short(&marker.name, 18),
|
||||
position: marker.capsule.position() + iced::Vector::new(8., 6.),
|
||||
size: 12.into(),
|
||||
color,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
MarkerKind::Death => {
|
||||
for (a, b) in death_strokes(marker.center) {
|
||||
frame.stroke(
|
||||
&Path::line(a, b),
|
||||
Stroke::default().with_color(color).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn birth_label_clip(
|
||||
pan: iced::Vector,
|
||||
capsule: Rectangle,
|
||||
scale: f32,
|
||||
viewport: Size,
|
||||
) -> Option<Rectangle> {
|
||||
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
pan.x + (capsule.x + 8.) * scale,
|
||||
pan.y + (capsule.y + 4.) * scale,
|
||||
),
|
||||
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
|
||||
)
|
||||
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
||||
}
|
||||
|
||||
fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
|
||||
match marker.event.kind {
|
||||
MarkerKind::Birth => {
|
||||
let dx = (marker.capsule.x - point.x)
|
||||
.max(point.x - marker.capsule.x - marker.capsule.width)
|
||||
.max(0.);
|
||||
let dy = (marker.capsule.y - point.y)
|
||||
.max(point.y - marker.capsule.y - marker.capsule.height)
|
||||
.max(0.);
|
||||
dx.hypot(dy)
|
||||
}
|
||||
MarkerKind::Death => death_strokes(marker.center)
|
||||
.iter()
|
||||
.map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
|
||||
.fold(f32::INFINITY, f32::min),
|
||||
}
|
||||
}
|
||||
|
||||
fn death_strokes(center: Point) -> [(Point, Point); 2] {
|
||||
[
|
||||
(
|
||||
center + iced::Vector::new(-7., -7.),
|
||||
center + iced::Vector::new(7., 7.),
|
||||
),
|
||||
(
|
||||
center + iced::Vector::new(-7., 7.),
|
||||
center + iced::Vector::new(7., -7.),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user