Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc49a7802 | ||
|
|
4686d05003 | ||
|
|
dfb6102ba5 | ||
|
|
be7a8cd8d7 | ||
|
|
f5e1c734da | ||
|
|
9613cf71aa | ||
|
|
08350bb3f4 | ||
|
|
36c105f3cd | ||
|
|
c5ea82ed50 | ||
|
|
8cf64cedb2 | ||
|
|
33c6d83e9b | ||
|
|
1e63dd69ed | ||
|
|
00a21d3322 | ||
|
|
eeeaa12627 | ||
|
|
f4e426ae2a | ||
|
|
fb16c4200f | ||
|
|
2dedde29e9 | ||
|
|
8e00d3ce4f | ||
|
|
36db9356cc | ||
|
|
fb24def2f5 | ||
|
|
b70bf1df45 | ||
|
|
b2c3e54001 | ||
|
|
ca53f574e5 | ||
|
|
c4bad4b8de | ||
|
|
d95b1ab178 | ||
|
|
df6f595626 | ||
|
|
07eb3cd12b | ||
|
|
27df4a3945 | ||
|
|
6a610616e7 | ||
|
|
01b76a1e89 | ||
|
|
2ec72d4241 | ||
|
|
4bb4aaf3dc | ||
|
|
d114a271c5 | ||
|
|
3978bbe23c | ||
|
|
81668f94ed | ||
|
|
ee97fcb1e3 | ||
|
|
b98f135799 | ||
|
|
a70de55ecd | ||
|
|
31b7dc3e15 | ||
|
|
057edddef8 | ||
|
|
b24a97360f | ||
|
|
7d1df4f369 |
+46
-18
@@ -15,35 +15,63 @@
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
PROJECT_GOAL.md -text whitespace=cr-at-eol
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
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
|
||||
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/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-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve the independently reviewed codec handoff bytes.
|
||||
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
|
||||
harness/experiments/wsl-capacity-20260911/*.log -whitespace
|
||||
harness/experiments/contextual-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-publication-20260911/** -text whitespace=cr-at-eol
|
||||
harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
|
||||
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
|
||||
|
||||
@@ -35,26 +35,26 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
|
||||
Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
|
||||
Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
|
||||
Generated
-64
@@ -65,15 +65,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -2116,25 +2107,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2181,13 +2153,9 @@ name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"stacker",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
@@ -2518,15 +2486,6 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2750,16 +2709,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3480,19 +3429,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -7,15 +7,11 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["unbounded_depth"] }
|
||||
stacker = "0.1.25"
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! Schema 8 engineering definitions and static admission. No expression evaluation.
|
||||
mod admission;
|
||||
mod expression_lifecycle;
|
||||
pub(crate) mod expression_serde;
|
||||
pub mod references;
|
||||
mod resolve;
|
||||
mod typecheck;
|
||||
mod types;
|
||||
|
||||
pub(crate) use admission::admit_structure_checked;
|
||||
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
|
||||
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
|
||||
pub use types::*;
|
||||
|
||||
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
|
||||
pub const MAX_EXPRESSION_DEPTH: usize = 128;
|
||||
pub const MAX_CLOSURE_NODES: usize = 65_536;
|
||||
pub const MAX_LIST_LENGTH: usize = 1_024;
|
||||
pub const MAX_CALL_DEPTH: usize = 32;
|
||||
|
||||
/// Additional persisted records for existing transport ParseLimits. Definitions
|
||||
/// themselves count as graph objects there; this counts parameters, overrides,
|
||||
/// and every stored expression node, with saturating arithmetic and no recursion.
|
||||
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
|
||||
let mut count = 0usize;
|
||||
fn binding(value: &Binding, count: &mut usize) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
references::walk(expression, &mut |_| {
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values() {
|
||||
if let DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
count = count.saturating_add(calculation.parameters.len());
|
||||
binding(&calculation.body, &mut count);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values() {
|
||||
match element.shared() {
|
||||
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
|
||||
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
count = count.saturating_add(overrides.len());
|
||||
for value in overrides.values() {
|
||||
binding(value, &mut count);
|
||||
}
|
||||
}
|
||||
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
|
||||
binding(predicate, &mut count)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,377 +0,0 @@
|
||||
//! Ordinary expression ownership never consumes stack proportional to tree depth.
|
||||
//! This also covers a partially decoded expression and rejected programmatic input.
|
||||
use super::Expr;
|
||||
use std::{fmt, mem};
|
||||
|
||||
impl Expr {
|
||||
fn clone_shell(&self) -> Self {
|
||||
let leaf = || Box::new(Self::Boolean(false));
|
||||
match self {
|
||||
Self::Boolean(value) => Self::Boolean(*value),
|
||||
Self::Number(value) => Self::Number(value.clone()),
|
||||
Self::Quantity { magnitude, unit } => Self::Quantity {
|
||||
magnitude: magnitude.clone(),
|
||||
unit: *unit,
|
||||
},
|
||||
Self::Property(id) => Self::Property(*id),
|
||||
Self::Parameter(id) => Self::Parameter(*id),
|
||||
Self::List(values) => {
|
||||
Self::List((0..values.len()).map(|_| Self::Boolean(false)).collect())
|
||||
}
|
||||
Self::Unary { op, .. } => Self::Unary {
|
||||
op: *op,
|
||||
operand: leaf(),
|
||||
},
|
||||
Self::Binary { op, .. } => Self::Binary {
|
||||
op: *op,
|
||||
left: leaf(),
|
||||
right: leaf(),
|
||||
},
|
||||
Self::If { .. } => Self::If {
|
||||
condition: leaf(),
|
||||
when_true: leaf(),
|
||||
when_false: leaf(),
|
||||
},
|
||||
Self::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => Self::Call {
|
||||
definition: *definition,
|
||||
arguments: arguments
|
||||
.keys()
|
||||
.map(|id| (*id, Self::Boolean(false)))
|
||||
.collect(),
|
||||
},
|
||||
Self::Sum { .. } => Self::Sum {
|
||||
values: leaf(),
|
||||
initial: leaf(),
|
||||
},
|
||||
Self::Power { exponent, .. } => Self::Power {
|
||||
base: leaf(),
|
||||
exponent: *exponent,
|
||||
},
|
||||
Self::AsKind { kind, .. } => Self::AsKind {
|
||||
kind: *kind,
|
||||
value: leaf(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn detach_children(&mut self, pending: &mut Vec<Self>) {
|
||||
let mut take = |child: &mut Box<Self>| {
|
||||
pending.push(mem::replace(child.as_mut(), Self::Boolean(false)));
|
||||
};
|
||||
match self {
|
||||
Self::Unary { operand, .. } => take(operand),
|
||||
Self::Binary { left, right, .. } => {
|
||||
take(left);
|
||||
take(right);
|
||||
}
|
||||
Self::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
take(condition);
|
||||
take(when_true);
|
||||
take(when_false);
|
||||
}
|
||||
Self::Sum { values, initial } => {
|
||||
take(values);
|
||||
take(initial);
|
||||
}
|
||||
Self::Power { base, .. } => take(base),
|
||||
Self::AsKind { value, .. } => take(value),
|
||||
Self::List(values) => pending.append(values),
|
||||
Self::Call { arguments, .. } => {
|
||||
pending.extend(mem::take(arguments).into_values());
|
||||
}
|
||||
Self::Boolean(_)
|
||||
| Self::Number(_)
|
||||
| Self::Quantity { .. }
|
||||
| Self::Property(_)
|
||||
| Self::Parameter(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Expr {
|
||||
fn drop(&mut self) {
|
||||
let mut pending = Vec::new();
|
||||
self.detach_children(&mut pending);
|
||||
while let Some(mut node) = pending.pop() {
|
||||
node.detach_children(&mut pending);
|
||||
// node now owns only leaves/empty collections. Its ordinary drop
|
||||
// reenters this method at most once, independently of original depth.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Expr {
|
||||
fn clone(&self) -> Self {
|
||||
let mut result = Self::Boolean(false);
|
||||
let mut pending = vec![(self, &mut result)];
|
||||
while let Some((source, destination)) = pending.pop() {
|
||||
*destination = source.clone_shell();
|
||||
match (source, destination) {
|
||||
(Self::List(a), Self::List(b)) => pending.extend(a.iter().zip(b.iter_mut())),
|
||||
(Self::Unary { operand: a, .. }, Self::Unary { operand: b, .. })
|
||||
| (Self::Power { base: a, .. }, Self::Power { base: b, .. })
|
||||
| (Self::AsKind { value: a, .. }, Self::AsKind { value: b, .. }) => {
|
||||
pending.push((a, b));
|
||||
}
|
||||
(
|
||||
Self::Binary {
|
||||
left: a, right: b, ..
|
||||
},
|
||||
Self::Binary {
|
||||
left: c, right: d, ..
|
||||
},
|
||||
)
|
||||
| (
|
||||
Self::Sum {
|
||||
values: a,
|
||||
initial: b,
|
||||
},
|
||||
Self::Sum {
|
||||
values: c,
|
||||
initial: d,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, c));
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::If {
|
||||
condition: a,
|
||||
when_true: b,
|
||||
when_false: c,
|
||||
},
|
||||
Self::If {
|
||||
condition: d,
|
||||
when_true: e,
|
||||
when_false: f,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, d));
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(Self::Call { arguments: a, .. }, Self::Call { arguments: b, .. }) => {
|
||||
pending.extend(a.values().zip(b.values_mut()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Expr {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let mut pending = vec![(self, other)];
|
||||
while let Some((a, b)) = pending.pop() {
|
||||
match (a, b) {
|
||||
(Self::Boolean(a), Self::Boolean(b)) if a == b => {}
|
||||
(Self::Number(a), Self::Number(b)) if a == b => {}
|
||||
(
|
||||
Self::Quantity {
|
||||
magnitude: a,
|
||||
unit: b,
|
||||
},
|
||||
Self::Quantity {
|
||||
magnitude: c,
|
||||
unit: d,
|
||||
},
|
||||
) if a == c && b == d => {}
|
||||
(Self::Property(a), Self::Property(b)) if a == b => {}
|
||||
(Self::Parameter(a), Self::Parameter(b)) if a == b => {}
|
||||
(Self::List(a), Self::List(b)) if a.len() == b.len() => {
|
||||
pending.extend(a.iter().zip(b.iter()));
|
||||
}
|
||||
(Self::Unary { op: a, operand: b }, Self::Unary { op: c, operand: d })
|
||||
if a == c =>
|
||||
{
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::Binary {
|
||||
op: a,
|
||||
left: b,
|
||||
right: c,
|
||||
},
|
||||
Self::Binary {
|
||||
op: d,
|
||||
left: e,
|
||||
right: f,
|
||||
},
|
||||
) if a == d => {
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(
|
||||
Self::If {
|
||||
condition: a,
|
||||
when_true: b,
|
||||
when_false: c,
|
||||
},
|
||||
Self::If {
|
||||
condition: d,
|
||||
when_true: e,
|
||||
when_false: f,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, d));
|
||||
pending.push((b, e));
|
||||
pending.push((c, f));
|
||||
}
|
||||
(
|
||||
Self::Call {
|
||||
definition: a,
|
||||
arguments: b,
|
||||
},
|
||||
Self::Call {
|
||||
definition: c,
|
||||
arguments: d,
|
||||
},
|
||||
) if a == c && b.len() == d.len() => {
|
||||
for ((key_a, value_a), (key_b, value_b)) in b.iter().zip(d.iter()) {
|
||||
if key_a != key_b {
|
||||
return false;
|
||||
}
|
||||
pending.push((value_a, value_b));
|
||||
}
|
||||
}
|
||||
(
|
||||
Self::Sum {
|
||||
values: a,
|
||||
initial: b,
|
||||
},
|
||||
Self::Sum {
|
||||
values: c,
|
||||
initial: d,
|
||||
},
|
||||
) => {
|
||||
pending.push((a, c));
|
||||
pending.push((b, d));
|
||||
}
|
||||
(
|
||||
Self::Power {
|
||||
base: a,
|
||||
exponent: b,
|
||||
},
|
||||
Self::Power {
|
||||
base: c,
|
||||
exponent: d,
|
||||
},
|
||||
) if b == d => {
|
||||
pending.push((a, c));
|
||||
}
|
||||
(Self::AsKind { kind: a, value: b }, Self::AsKind { kind: c, value: d })
|
||||
if a == c =>
|
||||
{
|
||||
pending.push((b, d));
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Eq for Expr {}
|
||||
|
||||
impl fmt::Debug for Expr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
enum Task<'a> {
|
||||
Node(&'a Expr),
|
||||
Text(&'static str),
|
||||
Scalar(&'a dyn fmt::Debug),
|
||||
}
|
||||
use Task::*;
|
||||
let mut pending = vec![Node(self)];
|
||||
while let Some(task) = pending.pop() {
|
||||
match task {
|
||||
Text(text) => f.write_str(text)?,
|
||||
Scalar(value) => write!(f, "{value:?}")?,
|
||||
Node(node) => match node {
|
||||
Self::Boolean(value) => write!(f, "Boolean({value:?})")?,
|
||||
Self::Number(value) => write!(f, "Number({value:?})")?,
|
||||
Self::Quantity { magnitude, unit } => {
|
||||
write!(f, "Quantity {{ magnitude: {magnitude:?}, unit: {unit:?} }}")?;
|
||||
}
|
||||
Self::Property(id) => write!(f, "Property({id:?})")?,
|
||||
Self::Parameter(id) => write!(f, "Parameter({id:?})")?,
|
||||
Self::List(values) => {
|
||||
f.write_str("List([")?;
|
||||
pending.push(Text("])"));
|
||||
for (index, value) in values.iter().enumerate().rev() {
|
||||
pending.push(Node(value));
|
||||
if index > 0 {
|
||||
pending.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Unary { op, operand } => {
|
||||
write!(f, "Unary {{ op: {op:?}, operand: ")?;
|
||||
pending.extend([Text(" }"), Node(operand)]);
|
||||
}
|
||||
Self::Binary { op, left, right } => {
|
||||
write!(f, "Binary {{ op: {op:?}, left: ")?;
|
||||
pending.extend([Text(" }"), Node(right), Text(", right: "), Node(left)]);
|
||||
}
|
||||
Self::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
f.write_str("If { condition: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Node(when_false),
|
||||
Text(", when_false: "),
|
||||
Node(when_true),
|
||||
Text(", when_true: "),
|
||||
Node(condition),
|
||||
]);
|
||||
}
|
||||
Self::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
write!(f, "Call {{ definition: {definition:?}, arguments: {{")?;
|
||||
pending.push(Text("} }"));
|
||||
for (index, (id, value)) in arguments.iter().enumerate().rev() {
|
||||
pending.extend([Node(value), Text(": "), Scalar(id)]);
|
||||
if index > 0 {
|
||||
pending.push(Text(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Sum { values, initial } => {
|
||||
f.write_str("Sum { values: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Node(initial),
|
||||
Text(", initial: "),
|
||||
Node(values),
|
||||
]);
|
||||
}
|
||||
Self::Power { base, exponent } => {
|
||||
f.write_str("Power { base: ")?;
|
||||
pending.extend([
|
||||
Text(" }"),
|
||||
Scalar(exponent),
|
||||
Text(", exponent: "),
|
||||
Node(base),
|
||||
]);
|
||||
}
|
||||
Self::AsKind { kind, value } => {
|
||||
write!(f, "AsKind {{ kind: {kind:?}, value: ")?;
|
||||
pending.extend([Text(" }"), Node(value)]);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
//! The existing externally tagged Serde schema, with admission at each Expr edge.
|
||||
//! The private remote derive is the sole wire grammar: it still rejects unknown
|
||||
//! fields/variants, duplicate fields and malformed UUIDs/Exact values.
|
||||
use super::{
|
||||
BinaryOp, DefinitionRef, Expr, MAX_CLOSURE_NODES, MAX_EXPRESSION_DEPTH, MAX_LIST_LENGTH,
|
||||
ParameterId, UnaryOp,
|
||||
};
|
||||
use crate::{Id, analysis_numeric::Exact};
|
||||
use serde::{
|
||||
Deserialize, Serialize,
|
||||
de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
|
||||
};
|
||||
use std::{cell::Cell, collections::BTreeMap, fmt};
|
||||
|
||||
pub(crate) const MAX_JSON_EXPRESSION_NODES: usize = 262_144;
|
||||
// These are requested segment capacities, not an allocator or RSS guarantee.
|
||||
const RED_ZONE: usize = 64 * 1024;
|
||||
const STACK_SEGMENT: usize = 512 * 1024;
|
||||
|
||||
pub(crate) fn with_stack<R>(operation: impl FnOnce() -> R) -> R {
|
||||
stacker::maybe_grow(RED_ZONE, STACK_SEGMENT, operation)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Meter {
|
||||
active: bool,
|
||||
depth: usize,
|
||||
nodes: usize,
|
||||
tree_nodes: usize,
|
||||
maximum: usize,
|
||||
}
|
||||
impl Meter {
|
||||
const DEFAULT: Self = Self {
|
||||
active: false,
|
||||
depth: 0,
|
||||
nodes: 0,
|
||||
tree_nodes: 0,
|
||||
maximum: MAX_JSON_EXPRESSION_NODES,
|
||||
};
|
||||
}
|
||||
thread_local! {
|
||||
static DECODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
|
||||
static ENCODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
|
||||
}
|
||||
type LocalMeter = &'static std::thread::LocalKey<Cell<Meter>>;
|
||||
|
||||
/// One codec operation owns the aggregate meter across all disjoint bindings.
|
||||
/// Scope restoration also runs on errors and unwinding; no mutable TLS borrow is
|
||||
/// held across Serde/user code or a stack switch.
|
||||
pub(crate) struct Scope {
|
||||
local: LocalMeter,
|
||||
previous: Meter,
|
||||
}
|
||||
impl Scope {
|
||||
pub(crate) fn decoding(maximum: usize) -> Self {
|
||||
Self::new(&DECODING, maximum)
|
||||
}
|
||||
pub(crate) fn encoding(maximum: usize) -> Self {
|
||||
Self::new(&ENCODING, maximum)
|
||||
}
|
||||
fn new(local: LocalMeter, maximum: usize) -> Self {
|
||||
let next = Meter {
|
||||
active: true,
|
||||
maximum: maximum.min(MAX_JSON_EXPRESSION_NODES),
|
||||
..Meter::DEFAULT
|
||||
};
|
||||
let previous = local.with(|meter| meter.replace(next));
|
||||
Self { local, previous }
|
||||
}
|
||||
}
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
self.local.with(|meter| meter.set(self.previous));
|
||||
}
|
||||
}
|
||||
|
||||
struct NodeGuard {
|
||||
local: LocalMeter,
|
||||
root: bool,
|
||||
}
|
||||
impl NodeGuard {
|
||||
fn enter(local: LocalMeter) -> Result<Self, &'static str> {
|
||||
local.with(|slot| {
|
||||
let mut meter = slot.get();
|
||||
let root = !meter.active;
|
||||
if root {
|
||||
meter = Meter {
|
||||
active: true,
|
||||
..Meter::DEFAULT
|
||||
};
|
||||
}
|
||||
if meter.depth == 0 {
|
||||
meter.tree_nodes = 0;
|
||||
}
|
||||
if meter.depth >= MAX_EXPRESSION_DEPTH {
|
||||
return Err("Expression logical depth exceeds 128.");
|
||||
}
|
||||
if meter.nodes >= meter.maximum {
|
||||
return Err("JSON expression allocation count exceeded.");
|
||||
}
|
||||
if meter.tree_nodes >= MAX_CLOSURE_NODES {
|
||||
return Err("Stored expression node count exceeds 65536.");
|
||||
}
|
||||
meter.depth += 1;
|
||||
meter.nodes += 1;
|
||||
meter.tree_nodes += 1;
|
||||
slot.set(meter);
|
||||
Ok(Self { local, root })
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Drop for NodeGuard {
|
||||
fn drop(&mut self) {
|
||||
self.local.with(|slot| {
|
||||
let mut meter = slot.get();
|
||||
meter.depth -= 1;
|
||||
if self.root {
|
||||
meter.active = false;
|
||||
}
|
||||
slot.set(meter);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Expr {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let _node = NodeGuard::enter(&DECODING).map_err(serde::de::Error::custom)?;
|
||||
with_stack(|| WireExpr::deserialize(deserializer))
|
||||
}
|
||||
}
|
||||
impl Serialize for Expr {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let _node = NodeGuard::enter(&ENCODING).map_err(serde::ser::Error::custom)?;
|
||||
match self {
|
||||
Self::List(values) if values.len() > MAX_LIST_LENGTH => {
|
||||
return Err(serde::ser::Error::custom(
|
||||
"Expression List exceeds 1024 items.",
|
||||
));
|
||||
}
|
||||
Self::Call { arguments, .. } if arguments.len() > MAX_CLOSURE_NODES => {
|
||||
return Err(serde::ser::Error::custom(
|
||||
"Call argument count exceeds 65536.",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
with_stack(|| WireExpr::serialize(self, serializer))
|
||||
}
|
||||
}
|
||||
|
||||
// A private remote derive avoids exposing an unguarded inherent deserialize on
|
||||
// public Expr. Children remain Expr, so every recursive edge enters the guard.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "Expr", deny_unknown_fields)]
|
||||
enum WireExpr {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: DefinitionRef,
|
||||
},
|
||||
Property(Id),
|
||||
Parameter(ParameterId),
|
||||
List(#[serde(deserialize_with = "bounded_list")] Vec<Expr>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expr>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expr>,
|
||||
when_true: Box<Expr>,
|
||||
when_false: Box<Expr>,
|
||||
},
|
||||
Call {
|
||||
definition: DefinitionRef,
|
||||
#[serde(deserialize_with = "bounded_arguments")]
|
||||
arguments: BTreeMap<ParameterId, Expr>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expr>,
|
||||
initial: Box<Expr>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expr>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: DefinitionRef,
|
||||
value: Box<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ListItem {
|
||||
allowed: bool,
|
||||
}
|
||||
impl<'de> DeserializeSeed<'de> for ListItem {
|
||||
type Value = Expr;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<Expr, D::Error> {
|
||||
if !self.allowed {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Expression List exceeds 1024 items.",
|
||||
));
|
||||
}
|
||||
Expr::deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
fn bounded_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Expr>, D::Error> {
|
||||
struct ListVisitor;
|
||||
impl<'de> Visitor<'de> for ListVisitor {
|
||||
type Value = Vec<Expr>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("at most 1024 expression items")
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = access.next_element_seed(ListItem {
|
||||
allowed: values.len() < MAX_LIST_LENGTH,
|
||||
})? {
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(ListVisitor)
|
||||
}
|
||||
fn bounded_arguments<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
|
||||
struct Arguments;
|
||||
impl<'de> Visitor<'de> for Arguments {
|
||||
type Value = BTreeMap<ParameterId, Expr>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("unique scoped parameter UUID keys")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = BTreeMap::new();
|
||||
while let Some(id) = access.next_key::<ParameterId>()? {
|
||||
if values.contains_key(&id) {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"Duplicate parameter ID {}",
|
||||
id.0
|
||||
)));
|
||||
}
|
||||
if values.len() >= MAX_CLOSURE_NODES {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Call argument count exceeds 65536.",
|
||||
));
|
||||
}
|
||||
// Count/duplicate checks precede construction of the next subtree.
|
||||
values.insert(id, access.next_value()?);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Arguments)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
|
||||
use super::*;
|
||||
use crate::{Element, Id, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| {
|
||||
if let Expr::Property(id) = node {
|
||||
found.insert(*id);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| match node {
|
||||
Expr::Quantity { unit, .. } => {
|
||||
found.insert(unit.local());
|
||||
}
|
||||
Expr::Call { definition, .. } => {
|
||||
found.insert(definition.local());
|
||||
}
|
||||
Expr::AsKind { kind, .. } => {
|
||||
found.insert(kind.local());
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter().rev()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(when_false);
|
||||
pending.push(when_true);
|
||||
pending.push(condition);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter_mut()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(left);
|
||||
pending.push(right);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(condition);
|
||||
pending.push(when_true);
|
||||
pending.push(when_false);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
|
||||
let mut result = BTreeSet::new();
|
||||
let mut binding = |value: &Binding| {
|
||||
if let Binding::Expression(expression) = value {
|
||||
result.extend(expression_properties(expression));
|
||||
}
|
||||
};
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
|
||||
Some(SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
for value in overrides.values() {
|
||||
binding(value);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
|
||||
_ => {}
|
||||
}
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { subject, .. }) => {
|
||||
result.insert(subject.id());
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(overrides.keys());
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(documented_constraint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
|
||||
let DefinitionRef::Local(id) = reference;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
if let ScalarType::Quantity(reference) = scalar {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
}
|
||||
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
walk_mut(expression, &mut |node| match node {
|
||||
Expr::Property(id) if *id == old => *id = new,
|
||||
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
|
||||
Expr::Call { definition, .. } => remap_ref(definition, old, new),
|
||||
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
|
||||
_ => {} // Parameter nodes and argument keys are scoped, never global.
|
||||
});
|
||||
}
|
||||
}
|
||||
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
|
||||
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check before any mutation so a map-key collision never silently discards a binding.
|
||||
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
|
||||
if old != new {
|
||||
for element in project.elements.values() {
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
|
||||
&& overrides.contains_key(&old)
|
||||
&& overrides.contains_key(&new)
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
element.id,
|
||||
"overrides",
|
||||
"analysis_reference_collision",
|
||||
"Reidentification would merge two distinct override keys.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
|
||||
for element in project.elements.values_mut() {
|
||||
match element.shared_mut() {
|
||||
Some(SharedBody::EngineeringProperty {
|
||||
subject,
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
}) => {
|
||||
remap_subject(subject, old, new);
|
||||
remap_type(value_type, old, new);
|
||||
if let Some(reference) = display_unit {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
remap_binding(base, old, new);
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if old != new
|
||||
&& let Some(value) = overrides.remove(&old)
|
||||
{
|
||||
overrides.insert(new, value);
|
||||
}
|
||||
for value in overrides.values_mut() {
|
||||
remap_binding(value, old, new);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if *documented_constraint == Some(old) {
|
||||
*documented_constraint = Some(new);
|
||||
}
|
||||
remap_binding(predicate, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values_mut() {
|
||||
match &mut definition.body {
|
||||
DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
algebra: QuantityAlgebra::Affine { difference },
|
||||
..
|
||||
}) => remap_ref(difference, old, new),
|
||||
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
|
||||
DefinitionBody::Calculation(calculation) => {
|
||||
for parameter in &mut calculation.parameters {
|
||||
remap_type(&mut parameter.value_type, old, new);
|
||||
}
|
||||
remap_type(&mut calculation.result, old, new);
|
||||
remap_binding(&mut calculation.body, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
|
||||
use crate::{Id, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
|
||||
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct KindHandle {
|
||||
model: Id,
|
||||
definition: Id,
|
||||
}
|
||||
impl KindHandle {
|
||||
pub fn definition(self) -> Id {
|
||||
self.definition
|
||||
}
|
||||
pub(crate) fn local(model: Id, definition: Id) -> Self {
|
||||
Self { model, definition }
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedScalar {
|
||||
Boolean,
|
||||
Numeric(NumericType<KindHandle>),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedType {
|
||||
Scalar(ResolvedScalar),
|
||||
List(ResolvedScalar),
|
||||
}
|
||||
impl ResolvedType {
|
||||
pub(crate) fn boolean() -> Self {
|
||||
Self::Scalar(ResolvedScalar::Boolean)
|
||||
}
|
||||
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
|
||||
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EffectiveBinding<'a> {
|
||||
pub binding: &'a Binding,
|
||||
pub origin: BindingOrigin,
|
||||
}
|
||||
|
||||
/// Constructed only by admission; exact source/capture provenance stays with its owner.
|
||||
/// Values from different accepted environments must not be mixed by an evaluator.
|
||||
pub struct ResolvedAnalysis<'p> {
|
||||
pub(super) project: &'p Project,
|
||||
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
|
||||
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
|
||||
pub(super) limits: AdmissionLimits,
|
||||
pub(super) usage: AdmissionUsage,
|
||||
}
|
||||
impl<'p> ResolvedAnalysis<'p> {
|
||||
pub fn project(&self) -> &'p Project {
|
||||
self.project
|
||||
}
|
||||
pub fn admission_usage(&self) -> AdmissionUsage {
|
||||
self.usage
|
||||
}
|
||||
pub fn admission_limits(&self) -> AdmissionLimits {
|
||||
self.limits
|
||||
}
|
||||
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
|
||||
self.project.definitions.get(&reference.local())
|
||||
}
|
||||
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
|
||||
self.kinds.get(&reference.local())
|
||||
}
|
||||
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
|
||||
self.units.get(&reference.local())
|
||||
}
|
||||
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
|
||||
match &self.definition(reference)?.body {
|
||||
DefinitionBody::Calculation(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
let scalar = match scalar {
|
||||
ScalarType::Boolean => ResolvedScalar::Boolean,
|
||||
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
|
||||
ScalarType::Quantity(reference) => {
|
||||
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
|
||||
}
|
||||
};
|
||||
Some(match value {
|
||||
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
|
||||
AnalysisType::List(_) => ResolvedType::List(scalar),
|
||||
})
|
||||
}
|
||||
pub fn effective_binding(
|
||||
&self,
|
||||
property: Id,
|
||||
context: EvaluationContext,
|
||||
) -> Result<EffectiveBinding<'p>, AnalysisError> {
|
||||
let element = self.project.elements.get(&property).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Property does not exist.",
|
||||
)
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Target is not an engineering property.",
|
||||
));
|
||||
};
|
||||
if let EvaluationContext::Case(id) = context {
|
||||
let case = self.project.elements.get(&id).ok_or_else(|| {
|
||||
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_case",
|
||||
"Context is not an analysis case.",
|
||||
));
|
||||
};
|
||||
if case.layer() != element.layer() {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_layer",
|
||||
"Case and property must share a layer.",
|
||||
));
|
||||
}
|
||||
if let Some(binding) = overrides.get(&property) {
|
||||
return Ok(EffectiveBinding {
|
||||
binding,
|
||||
origin: BindingOrigin::CaseOverride(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(EffectiveBinding {
|
||||
binding: base,
|
||||
origin: BindingOrigin::ModelBaseline,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,458 +0,0 @@
|
||||
//! Static expression checking shares numeric operator algebra with execution.
|
||||
use super::admission::Budget;
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
|
||||
use crate::{Id, Layer, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) struct BindingContext<'a> {
|
||||
pub owner: Id,
|
||||
pub field: &'a str,
|
||||
pub layer: Option<Layer>,
|
||||
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
}
|
||||
|
||||
pub(super) fn binding(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
context: BindingContext<'_>,
|
||||
value: &Binding,
|
||||
expected: &ResolvedType,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
if let Binding::Expression(expression) = value {
|
||||
let mut checker = Checker {
|
||||
environment,
|
||||
owner: context.owner,
|
||||
field: context.field,
|
||||
layer: context.layer,
|
||||
parameters: context.parameters,
|
||||
budget,
|
||||
};
|
||||
checker.expression(expression, Some(expected))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Checker<'a, 'p, 'b> {
|
||||
environment: &'a ResolvedAnalysis<'p>,
|
||||
owner: Id,
|
||||
field: &'a str,
|
||||
layer: Option<Layer>,
|
||||
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
budget: &'b mut Budget,
|
||||
}
|
||||
|
||||
/// One suspended expression per logical depth. No Expr is owned or cloned by
|
||||
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
|
||||
struct Frame<'e, 'p> {
|
||||
expression: &'e Expr,
|
||||
expected: Option<ResolvedType>,
|
||||
state: State<'p>,
|
||||
}
|
||||
|
||||
enum State<'p> {
|
||||
Enter,
|
||||
Unary,
|
||||
BinaryLeft,
|
||||
BinaryRight(ResolvedType),
|
||||
IfCondition,
|
||||
IfTrue,
|
||||
IfFalse(ResolvedType),
|
||||
List {
|
||||
next: usize,
|
||||
},
|
||||
Call {
|
||||
calculation: &'p CalculationDefinition,
|
||||
next: usize,
|
||||
},
|
||||
SumInitial,
|
||||
SumValues(ResolvedType),
|
||||
Power,
|
||||
AsKind(ResolvedKind<KindHandle>),
|
||||
}
|
||||
|
||||
enum Step<'e> {
|
||||
Child(&'e Expr, Option<ResolvedType>),
|
||||
Complete(ResolvedType),
|
||||
}
|
||||
|
||||
impl<'p> Checker<'_, 'p, '_> {
|
||||
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
|
||||
AnalysisError::new(self.owner, self.field, code, message)
|
||||
}
|
||||
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
|
||||
match value {
|
||||
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
|
||||
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
|
||||
}
|
||||
}
|
||||
fn operation(
|
||||
&self,
|
||||
result: Result<NumericType<KindHandle>, NumericError>,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
result
|
||||
.map(ResolvedType::numeric)
|
||||
.map_err(|error| self.error("analysis_type", error.to_string()))
|
||||
}
|
||||
|
||||
/// Explicit continuations replace Rust recursion. Width never expands the
|
||||
/// frame stack: lists and calls schedule only their next ordered child.
|
||||
/// At most 128 frames and one returned scalar/list type are retained. The
|
||||
/// per-node/per-argument meter charges remain identical to recursive entry;
|
||||
/// continuation steps do constant bounded work within those charges.
|
||||
fn expression(
|
||||
&mut self,
|
||||
expression: &Expr,
|
||||
expected: Option<&ResolvedType>,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
let mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected: expected.cloned(),
|
||||
state: State::Enter,
|
||||
});
|
||||
let mut returned = None;
|
||||
loop {
|
||||
let frame = frames.last_mut().expect("active expression frame");
|
||||
match self.advance(frame, returned.take())? {
|
||||
Step::Child(expression, expected) => {
|
||||
if frames.len() == MAX_EXPRESSION_DEPTH {
|
||||
// Preserve entry charging/error precedence without ever
|
||||
// allocating the rejected 129th frame.
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return Err(self.error(
|
||||
"analysis_depth",
|
||||
"Expression exceeds the logical depth limit.",
|
||||
));
|
||||
}
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected,
|
||||
state: State::Enter,
|
||||
});
|
||||
}
|
||||
Step::Complete(actual) => {
|
||||
let frame = frames.pop().expect("completed expression frame");
|
||||
let expected = frame.expected.as_ref();
|
||||
if expected.is_some_and(|expected| expected != &actual) {
|
||||
return Err(self.error(
|
||||
"analysis_type",
|
||||
format!("Expected {expected:?}; expression has {actual:?}."),
|
||||
));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Ok(actual);
|
||||
}
|
||||
returned = Some(actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance<'e>(
|
||||
&mut self,
|
||||
frame: &mut Frame<'e, 'p>,
|
||||
returned: Option<ResolvedType>,
|
||||
) -> Result<Step<'e>, AnalysisError> {
|
||||
let state = std::mem::replace(&mut frame.state, State::Enter);
|
||||
if let State::Enter = state {
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return self.enter(frame);
|
||||
}
|
||||
let child = returned.expect("continuation follows a completed child");
|
||||
match state {
|
||||
State::Enter => unreachable!("handled before consuming child"),
|
||||
State::Unary => {
|
||||
let Expr::Unary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(match op {
|
||||
UnaryOp::Not => ResolvedType::boolean(),
|
||||
UnaryOp::Negate | UnaryOp::Absolute => {
|
||||
self.operation(self.numeric(child)?.unary_result())?
|
||||
}
|
||||
}))
|
||||
}
|
||||
State::BinaryLeft => {
|
||||
let Expr::Binary { right, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::BinaryRight(child);
|
||||
Ok(Step::Child(right, None))
|
||||
}
|
||||
State::BinaryRight(left) => {
|
||||
let Expr::Binary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(self.binary(*op, left, child)?))
|
||||
}
|
||||
State::IfCondition => {
|
||||
let Expr::If { when_true, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfTrue;
|
||||
Ok(Step::Child(when_true, frame.expected.clone()))
|
||||
}
|
||||
State::IfTrue => {
|
||||
let Expr::If { when_false, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfFalse(child.clone());
|
||||
Ok(Step::Child(when_false, Some(child)))
|
||||
}
|
||||
State::IfFalse(value) => Ok(Step::Complete(value)),
|
||||
State::List { next } => {
|
||||
let ResolvedType::Scalar(scalar) = child else {
|
||||
return Err(self.error(
|
||||
"analysis_list",
|
||||
"Lists are flat; nested lists are not admitted.",
|
||||
));
|
||||
};
|
||||
let Expr::List(items) = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(item) = items.get(next) {
|
||||
frame.state = State::List { next: next + 1 };
|
||||
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
|
||||
} else {
|
||||
Ok(Step::Complete(ResolvedType::List(scalar)))
|
||||
}
|
||||
}
|
||||
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
|
||||
State::SumInitial => {
|
||||
let numeric = self.numeric(child.clone())?;
|
||||
self.operation(numeric.unary_result())?;
|
||||
let ResolvedType::Scalar(scalar) = &child else {
|
||||
unreachable!()
|
||||
};
|
||||
let expected = ResolvedType::List(scalar.clone());
|
||||
let Expr::Sum { values, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::SumValues(child);
|
||||
Ok(Step::Child(values, Some(expected)))
|
||||
}
|
||||
State::SumValues(initial) => {
|
||||
let numeric = self.numeric(initial.clone())?;
|
||||
self.operation(numeric.additive_result(&numeric, false))?;
|
||||
Ok(Step::Complete(initial))
|
||||
}
|
||||
State::Power => {
|
||||
let Expr::Power { exponent, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.power_result(*exponent))?,
|
||||
))
|
||||
}
|
||||
State::AsKind(kind) => Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.assertion_result(&kind))?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
|
||||
let actual = match frame.expression {
|
||||
Expr::Boolean(_) => ResolvedType::boolean(),
|
||||
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
|
||||
Expr::Quantity { unit, .. } => {
|
||||
let unit = self.environment.unit(*unit).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_unit",
|
||||
"Quantity literal must reference an existing unit definition.",
|
||||
)
|
||||
})?;
|
||||
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
|
||||
}
|
||||
Expr::Property(id) => {
|
||||
let layer = self.layer.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_purity",
|
||||
"Reusable calculations cannot capture project properties.",
|
||||
)
|
||||
})?;
|
||||
let element =
|
||||
self.environment.project.elements.get(id).ok_or_else(|| {
|
||||
self.error("analysis_property", "Property does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
|
||||
else {
|
||||
return Err(self.error(
|
||||
"analysis_property",
|
||||
"Reference must target an engineering property.",
|
||||
));
|
||||
};
|
||||
if element.layer() != layer {
|
||||
return Err(self.error(
|
||||
"analysis_layer",
|
||||
"Property references must stay in the same layer.",
|
||||
));
|
||||
}
|
||||
self.environment.declared_type(value_type).ok_or_else(|| {
|
||||
self.error("analysis_kind", "Declared property kind does not resolve.")
|
||||
})?
|
||||
}
|
||||
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_parameter",
|
||||
"Parameter does not belong to this calculation definition.",
|
||||
)
|
||||
})?,
|
||||
Expr::List(items) => {
|
||||
if items.len() > MAX_LIST_LENGTH {
|
||||
return Err(
|
||||
self.error("analysis_list", "List exceeds the flat-list length limit.")
|
||||
);
|
||||
}
|
||||
let scalar = match &frame.expected {
|
||||
Some(ResolvedType::List(value)) => Some(value.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = items.first() {
|
||||
frame.state = State::List { next: 1 };
|
||||
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
|
||||
}
|
||||
ResolvedType::List(scalar.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_list",
|
||||
"An empty list requires an explicit surrounding list type.",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
Expr::Unary { op, operand } => {
|
||||
frame.state = State::Unary;
|
||||
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
|
||||
return Ok(Step::Child(operand, expected));
|
||||
}
|
||||
Expr::Binary { left, .. } => {
|
||||
frame.state = State::BinaryLeft;
|
||||
return Ok(Step::Child(left, None));
|
||||
}
|
||||
Expr::If { condition, .. } => {
|
||||
frame.state = State::IfCondition;
|
||||
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
|
||||
}
|
||||
Expr::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_calculation",
|
||||
"Call must reference a calculation definition.",
|
||||
)
|
||||
})?;
|
||||
if arguments.len() != calculation.parameters.len() {
|
||||
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
|
||||
}
|
||||
// Charge the complete key scan before any parameter lookup.
|
||||
self.budget
|
||||
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
|
||||
if calculation
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|parameter| !arguments.contains_key(¶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),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
|
||||
use crate::Id;
|
||||
use crate::analysis_numeric::{Dimension, Exact};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Only real local definitions are admitted in schema 8's first environment.
|
||||
/// Imported references require the separately implemented pinned package catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionRef {
|
||||
Local(Id),
|
||||
}
|
||||
impl DefinitionRef {
|
||||
pub fn local(self) -> Id {
|
||||
match self {
|
||||
Self::Local(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Qualified by its owning CalculationDefinition; never a global model identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ParameterId(pub Id);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Definition {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: DefinitionBody,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionBody {
|
||||
QuantityKind(QuantityKindDefinition),
|
||||
Unit(UnitDefinition),
|
||||
Calculation(CalculationDefinition),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct QuantityKindDefinition {
|
||||
pub dimension: Dimension,
|
||||
pub algebra: QuantityAlgebra,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum QuantityAlgebra {
|
||||
Linear,
|
||||
Affine { difference: DefinitionRef },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnitDefinition {
|
||||
pub kind: DefinitionRef,
|
||||
pub scale: Exact,
|
||||
pub offset: Exact,
|
||||
pub symbol: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ScalarType {
|
||||
Boolean,
|
||||
Number,
|
||||
Quantity(DefinitionRef),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisType {
|
||||
Scalar(ScalarType),
|
||||
List(ScalarType),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Parameter {
|
||||
pub id: ParameterId,
|
||||
pub name: String,
|
||||
pub value_type: AnalysisType,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CalculationDefinition {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub result: AnalysisType,
|
||||
pub body: Binding,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Binding {
|
||||
Unbound,
|
||||
Expression(Expr),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum UnaryOp {
|
||||
Negate,
|
||||
Absolute,
|
||||
Not,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BinaryOp {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Equal,
|
||||
NotEqual,
|
||||
Less,
|
||||
LessOrEqual,
|
||||
Greater,
|
||||
GreaterOrEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
pub enum Expr {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: DefinitionRef,
|
||||
},
|
||||
Property(Id),
|
||||
Parameter(ParameterId),
|
||||
List(Vec<Expr>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expr>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expr>,
|
||||
when_true: Box<Expr>,
|
||||
when_false: Box<Expr>,
|
||||
},
|
||||
Call {
|
||||
definition: DefinitionRef,
|
||||
arguments: BTreeMap<ParameterId, Expr>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expr>,
|
||||
initial: Box<Expr>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expr>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: DefinitionRef,
|
||||
value: Box<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisSubject {
|
||||
Element(Id),
|
||||
Relation(Id),
|
||||
}
|
||||
impl AnalysisSubject {
|
||||
pub fn id(self) -> Id {
|
||||
match self {
|
||||
Self::Element(id) | Self::Relation(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BaselineBase {
|
||||
ModelBaseline,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum EvaluationContext {
|
||||
ModelBaseline,
|
||||
Case(Id),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum BindingOrigin {
|
||||
ModelBaseline,
|
||||
CaseOverride(Id),
|
||||
}
|
||||
|
||||
/// Located structural/static admission failure, not a false or failed evaluation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[error("{code} at {object}.{field}: {message}")]
|
||||
pub struct AnalysisError {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
impl AnalysisError {
|
||||
pub(crate) fn new(
|
||||
object: Id,
|
||||
field: impl Into<String>,
|
||||
code: &str,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
object,
|
||||
field: field.into(),
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,107 +0,0 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -1,240 +0,0 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
pub fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericType<K> {
|
||||
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
|
||||
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
|
||||
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
|
||||
(None, None) if a == b => Ok(Named(a.clone())),
|
||||
(Some(difference), Some(_)) if subtract && a == b => {
|
||||
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
|
||||
}
|
||||
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
|
||||
Ok(Named(a.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract && difference == &a.key && a.dimension == b.dimension =>
|
||||
{
|
||||
Ok(Named(b.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::Number;
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
if left.is_affine() || right.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(left),
|
||||
(Number, _) if !divide => Ok(right),
|
||||
_ => Ok(Self::anonymous(if divide {
|
||||
left.dimension().checked_quotient(right.dimension())?
|
||||
} else {
|
||||
left.dimension().checked_product(right.dimension())?
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unary_result(&self) -> Result<Self, NumericError> {
|
||||
if self.is_affine() {
|
||||
Err(NumericError::AffineOperation)
|
||||
} else {
|
||||
Ok(self.clone().normalized())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(match exponent {
|
||||
0 => Self::Number,
|
||||
1 => self.clone().normalized(),
|
||||
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
|
||||
if self.clone().normalized() == rhs.clone().normalized() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
|
||||
if target.is_affine()
|
||||
|| matches!(self, Self::Named(_))
|
||||
|| self.dimension() != target.dimension
|
||||
{
|
||||
Err(NumericError::KindAssertion)
|
||||
} else {
|
||||
Ok(Self::Named(target.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.negated(meter)?))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.absolute(meter)?))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.power_result(exponent)?;
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
self.value_type.comparison_compatible(&rhs.value_type)?;
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.value_type.assertion_result(target)?;
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(output, self.coordinate.clone()))
|
||||
}
|
||||
}
|
||||
@@ -1,804 +0,0 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
+279
-246
@@ -1,12 +1,6 @@
|
||||
//! 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::*;
|
||||
@@ -66,8 +60,7 @@ impl ParseLimits {
|
||||
let graph = project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(project.definitions.len());
|
||||
.saturating_add(project.relations.len());
|
||||
let records = graph
|
||||
.saturating_add(1)
|
||||
.saturating_add(project.diagrams.len())
|
||||
@@ -86,8 +79,7 @@ impl ParseLimits {
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
.saturating_add(project.traces.len())
|
||||
.saturating_add(crate::analysis::nested_record_count(project));
|
||||
.saturating_add(project.traces.len());
|
||||
let steps = project
|
||||
.elements
|
||||
.values()
|
||||
@@ -150,10 +142,164 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
raw_tokens::legacy(source)
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
cursor += 1;
|
||||
while source
|
||||
.as_bytes()
|
||||
.get(cursor)
|
||||
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
TokenClass::Whitespace
|
||||
} else if source[cursor..].starts_with("//") {
|
||||
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
|
||||
TokenClass::Comment
|
||||
} else if ch == '"' {
|
||||
cursor += 1;
|
||||
let mut escaped = false;
|
||||
let mut closed = false;
|
||||
while cursor < source.len() {
|
||||
let next = source[cursor..].chars().next().expect("in bounds");
|
||||
cursor += next.len_utf8();
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if next == '\\' {
|
||||
escaped = true;
|
||||
} else if next == '"' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
|
||||
TokenClass::String
|
||||
} else {
|
||||
TokenClass::Error
|
||||
}
|
||||
} else if ch.is_ascii_alphabetic() || ch == '_' {
|
||||
cursor += 1;
|
||||
while source
|
||||
.as_bytes()
|
||||
.get(cursor)
|
||||
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if reserved(&source[start..cursor])
|
||||
|| matches!(&source[start..cursor], "true" | "false" | "none")
|
||||
{
|
||||
TokenClass::Keyword
|
||||
} else {
|
||||
TokenClass::Identifier
|
||||
}
|
||||
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
|
||||
cursor += 1;
|
||||
while source.as_bytes().get(cursor).is_some_and(|next| {
|
||||
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
|
||||
}) {
|
||||
cursor += 1;
|
||||
}
|
||||
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
|
||||
TokenClass::Number
|
||||
} else {
|
||||
TokenClass::Error
|
||||
}
|
||||
} else if "{}();,[]:=@".contains(ch) {
|
||||
cursor += ch.len_utf8();
|
||||
TokenClass::Punctuation
|
||||
} else {
|
||||
cursor += ch.len_utf8();
|
||||
TokenClass::Error
|
||||
};
|
||||
tokens.push(TokenSpan {
|
||||
class,
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -480,8 +626,6 @@ 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)])
|
||||
@@ -916,8 +1060,6 @@ 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)))
|
||||
@@ -1009,52 +1151,31 @@ fn edit_positions(
|
||||
unit: SourceUnit,
|
||||
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
||||
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
||||
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 ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
||||
let mut stack = Vec::new();
|
||||
for index in 0..count {
|
||||
let token = token(index);
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
match token.text(source) {
|
||||
"{" | "[" => stack.push(index),
|
||||
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
struct Positions<'a, F> {
|
||||
token_at: F,
|
||||
struct Positions<'a> {
|
||||
tokens: &'a [Lexeme],
|
||||
ends: Vec<usize>,
|
||||
value_ends: Option<&'a BTreeMap<usize, usize>>,
|
||||
}
|
||||
impl<F: Fn(usize) -> Lexeme> Positions<'_, F> {
|
||||
impl Positions<'_> {
|
||||
fn token(&self, start: usize) -> usize {
|
||||
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
|
||||
self.tokens
|
||||
.partition_point(|token| token.span.start < start)
|
||||
}
|
||||
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, ast)| {
|
||||
let value = self.value(ast);
|
||||
let start = (self.token_at)(self.token(value.span.start) - 1).span.start;
|
||||
let end = (self.token_at)(self.token(value.span.end)).span.end;
|
||||
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
||||
let end = self.tokens[self.token(value.span.end)].span.end;
|
||||
(
|
||||
key.clone(),
|
||||
EditField {
|
||||
@@ -1069,15 +1190,10 @@ fn edit_positions_from_tokens(
|
||||
let index = self.token(ast.offset());
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
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],
|
||||
}),
|
||||
end: match ast {
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
};
|
||||
let kind = match ast {
|
||||
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
||||
@@ -1092,12 +1208,11 @@ fn edit_positions_from_tokens(
|
||||
}
|
||||
}
|
||||
let positions = Positions {
|
||||
token_at: &token,
|
||||
tokens: &tokens,
|
||||
ends,
|
||||
value_ends,
|
||||
};
|
||||
unit.declarations
|
||||
.iter()
|
||||
.into_iter()
|
||||
.map(|declaration| {
|
||||
if declaration.id_insertion.is_some() {
|
||||
return Err(Box::new(diagnostic(
|
||||
@@ -1117,8 +1232,8 @@ fn edit_positions_from_tokens(
|
||||
let start = positions.token(declaration.span.start);
|
||||
let header = roles
|
||||
.iter()
|
||||
.zip(start..count)
|
||||
.map(|(role, at)| (*role, token(at).span))
|
||||
.zip(&tokens[start..])
|
||||
.map(|(role, token)| (*role, token.span))
|
||||
.collect();
|
||||
Ok((
|
||||
declaration.id,
|
||||
@@ -1145,35 +1260,10 @@ 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.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(())
|
||||
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
||||
}
|
||||
fn text(&self) -> &str {
|
||||
self.current().text(self.source)
|
||||
@@ -1210,20 +1300,12 @@ 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 value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
if !has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1254,7 +1336,6 @@ 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,
|
||||
@@ -1265,19 +1346,15 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let start = self.current().span.start;
|
||||
let value = self.value()?;
|
||||
let end = self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end;
|
||||
let end = self.tokens[self.cursor - 1].span.end;
|
||||
self.take(";")?;
|
||||
Ok((key, value, Span { start, end }))
|
||||
}
|
||||
|
||||
fn nested_header_kind(&self) -> Option<DeclKind> {
|
||||
let kind = declaration_kind(self.text())?;
|
||||
let alias = self.token(self.cursor + 1)?;
|
||||
let following = self.token(self.cursor + 2)?;
|
||||
let alias = self.tokens.get(self.cursor + 1)?;
|
||||
let following = self.tokens.get(self.cursor + 2)?;
|
||||
// A node's ordinary `diagram @view;` field is not a declaration.
|
||||
if !crate::valid_alias(alias.text(self.source)) {
|
||||
return None;
|
||||
@@ -1292,33 +1369,14 @@ 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;
|
||||
@@ -1416,31 +1474,16 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
#[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."
|
||||
}));
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
||||
return Err(
|
||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
||||
);
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
self.take("project")?;
|
||||
#[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);
|
||||
@@ -1454,7 +1497,6 @@ impl Parser<'_> {
|
||||
alias,
|
||||
name,
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1481,14 +1523,6 @@ 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;
|
||||
@@ -1532,14 +1566,6 @@ 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(";")?;
|
||||
@@ -1547,11 +1573,7 @@ impl Parser<'_> {
|
||||
path,
|
||||
Span {
|
||||
start: offset,
|
||||
end: self
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end,
|
||||
end: self.tokens[self.cursor - 1].span.end,
|
||||
},
|
||||
));
|
||||
continue;
|
||||
@@ -1563,40 +1585,20 @@ 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
|
||||
.token(self.cursor - 1)
|
||||
.expect("consumed token")
|
||||
.span
|
||||
.end,
|
||||
end: self.tokens[self.cursor - 1].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(..)
|
||||
@@ -1624,10 +1626,6 @@ 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,
|
||||
@@ -1664,25 +1662,17 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// The construction phase is shared with private mixed resolution. Migration and
|
||||
// complete-model admission remain exclusively in each caller's finishing tail.
|
||||
struct ConstructedDeclarations {
|
||||
project: Project,
|
||||
legacy_elements: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
fn construct_declarations<'a>(
|
||||
pub(crate) fn resolve_declarations(
|
||||
mut project: Project,
|
||||
declarations: impl IntoIterator<Item = &'a Declaration>,
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
mut before_fields: impl FnMut(&Declaration) -> Result<(), Error>,
|
||||
) -> Result<ConstructedDeclarations, (usize, Error)> {
|
||||
limits: ParseLimits,
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
let mut nodes = Vec::new();
|
||||
for (index, declaration) in declarations.into_iter().enumerate() {
|
||||
for (index, declaration) in declarations.iter().enumerate() {
|
||||
(|| -> Result<(), Error> {
|
||||
before_fields(declaration)?;
|
||||
let mut fields = declaration
|
||||
.fields
|
||||
.iter()
|
||||
@@ -1727,9 +1717,6 @@ fn construct_declarations<'a>(
|
||||
}
|
||||
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(
|
||||
@@ -1907,25 +1894,11 @@ fn construct_declarations<'a>(
|
||||
.map_err(|error| (index, error))?;
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
Ok(ConstructedDeclarations {
|
||||
project,
|
||||
legacy_elements,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_declarations(
|
||||
project: Project,
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let ConstructedDeclarations {
|
||||
project,
|
||||
legacy_elements,
|
||||
} = construct_declarations(project, declarations, aliases, |_| Ok(()))?;
|
||||
if legacy {
|
||||
let migrated = source3_bridge::from_scratch(project, legacy_elements)
|
||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
||||
value["elements"] = Value::Object(legacy_elements);
|
||||
let migrated = serde_json::from_value(value)
|
||||
.map_err(crate::persistence::FileError::Json)
|
||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||
.map_err(|error| {
|
||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||
@@ -1973,15 +1946,13 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if matches!(project.schema_version, 4..=7) {
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else if project.schema_version == 6 {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v7(project, limits)
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
@@ -2062,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -2072,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -2107,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -2115,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
id = Id::parse_str(&text).ok();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2542,11 +2579,7 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Unresolved typed source8 syntax, staged for the complete source adapter.
|
||||
//! This module does not admit a Project or enable public source8.
|
||||
mod budget;
|
||||
mod cursor;
|
||||
mod declarations;
|
||||
mod 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;
|
||||
@@ -1,162 +0,0 @@
|
||||
//! Candidate limits for measured adoption; one owner spans all parsed files.
|
||||
use super::{Error, Span, error};
|
||||
use crate::analysis_numeric::{Exact, WorkLimits, WorkMeter};
|
||||
use crate::language::ParseLimits;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub references: usize,
|
||||
pub frames: usize,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
impl Limits {
|
||||
pub fn source8(caller: ParseLimits) -> Self {
|
||||
let bytes = caller.max_source_bytes.min(64 * 1024 * 1024);
|
||||
Self {
|
||||
bytes,
|
||||
tokens: bytes.min(1_048_576),
|
||||
entries: caller.max_records.min(262_144),
|
||||
references: caller.max_step_references.min(500_000),
|
||||
frames: 256,
|
||||
numeric_units: 50_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub references: usize,
|
||||
pub deepest_frame: usize,
|
||||
}
|
||||
pub struct Budget {
|
||||
limits: Limits,
|
||||
pub usage: Usage,
|
||||
numeric: WorkMeter,
|
||||
failed: Option<Error>,
|
||||
}
|
||||
impl Budget {
|
||||
pub fn new(limits: Limits) -> Result<Self, Error> {
|
||||
let hard = Limits::source8(ParseLimits::UNLIMITED);
|
||||
if limits.bytes > hard.bytes
|
||||
|| limits.tokens > hard.tokens
|
||||
|| limits.entries > hard.entries
|
||||
|| limits.references > hard.references
|
||||
|| limits.frames > hard.frames
|
||||
|| limits.numeric_units > hard.numeric_units
|
||||
{
|
||||
return Err(error(
|
||||
Span::default(),
|
||||
"Source8 profile cannot exceed candidate hard limits.",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
limits,
|
||||
usage: Usage::default(),
|
||||
numeric: WorkMeter::new(
|
||||
WorkLimits::new(0, limits.numeric_units)
|
||||
.map_err(|e| error(Span::default(), e.to_string()))?,
|
||||
),
|
||||
failed: None,
|
||||
})
|
||||
}
|
||||
pub fn active(&self) -> Result<(), Error> {
|
||||
self.failed.clone().map_or(Ok(()), Err)
|
||||
}
|
||||
fn charge(
|
||||
&mut self,
|
||||
used: usize,
|
||||
amount: usize,
|
||||
maximum: usize,
|
||||
span: Span,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
if amount > maximum.saturating_sub(used) {
|
||||
let failure = error(span, format!("Source8 {name} limit exceeded."));
|
||||
self.failed = Some(failure.clone());
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn bytes(&mut self, amount: usize, span: Span) -> Result<(), Error> {
|
||||
self.charge(self.usage.bytes, amount, self.limits.bytes, span, "bytes")?;
|
||||
self.usage.bytes += amount;
|
||||
Ok(())
|
||||
}
|
||||
pub fn token(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(self.usage.tokens, 1, self.limits.tokens, span, "tokens")?;
|
||||
self.usage.tokens += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn entry(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(
|
||||
self.usage.entries,
|
||||
1,
|
||||
self.limits.entries,
|
||||
span,
|
||||
"syntax entries",
|
||||
)?;
|
||||
self.usage.entries += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn reference(&mut self, span: Span) -> Result<(), Error> {
|
||||
self.charge(
|
||||
self.usage.references,
|
||||
1,
|
||||
self.limits.references,
|
||||
span,
|
||||
"references",
|
||||
)?;
|
||||
self.usage.references += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub fn frame(&mut self, depth: usize, span: Span) -> Result<(), Error> {
|
||||
self.charge(0, depth, self.limits.frames, span, "syntax frames")?;
|
||||
self.usage.deepest_frame = self.usage.deepest_frame.max(depth);
|
||||
Ok(())
|
||||
}
|
||||
pub fn exact(&mut self, spelling: &str, span: Span) -> Result<Exact, Error> {
|
||||
self.active()?;
|
||||
match Exact::parse_literal(spelling, &mut self.numeric) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(cause) => {
|
||||
let failure = error(span, cause.to_string());
|
||||
self.failed = Some(failure.clone());
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn numeric_units(&self) -> u64 {
|
||||
self.numeric.usage().numeric_units
|
||||
}
|
||||
/// Check phase allocation bounds without recharging parsed syntax or exact
|
||||
/// work. S4 must also charge ordinary records through the inherited budget.
|
||||
pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> {
|
||||
self.active()?;
|
||||
if count > self.limits.entries {
|
||||
return Err(error(span, "Source8 lowering entry limit exceeded."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! A borrowed significant-token cursor. Spans always refer to the full source.
|
||||
use super::*;
|
||||
use lex::{Class, Token};
|
||||
|
||||
pub(super) struct Cursor<'a, 'b> {
|
||||
pub source: &'a str,
|
||||
pub tokens: &'a [Token],
|
||||
pub cursor: usize,
|
||||
pub frames: usize,
|
||||
pub budget: &'b mut Budget,
|
||||
}
|
||||
impl<'a, 'b> Cursor<'a, 'b> {
|
||||
pub(super) fn new(source: &'a str, tokens: &'a [Token], budget: &'b mut Budget) -> Self {
|
||||
Self {
|
||||
source,
|
||||
tokens,
|
||||
cursor: 0,
|
||||
frames: 0,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Cursor<'_, '_> {
|
||||
pub(super) fn span(&self) -> Span {
|
||||
self.tokens.get(self.cursor).map_or(
|
||||
Span {
|
||||
start: self.source.len(),
|
||||
end: self.source.len(),
|
||||
},
|
||||
|token| token.span,
|
||||
)
|
||||
}
|
||||
pub(super) fn text(&self) -> &str {
|
||||
let span = self.span();
|
||||
&self.source[span.start..span.end]
|
||||
}
|
||||
pub(super) fn take(&mut self, expected: &str) -> Result<Span, Error> {
|
||||
if self.text() != expected {
|
||||
return Err(error(self.span(), format!("Expected '{expected}'.")));
|
||||
}
|
||||
let span = self.span();
|
||||
self.cursor += 1;
|
||||
Ok(span)
|
||||
}
|
||||
pub(super) fn end(&self) -> Result<(), Error> {
|
||||
if self.cursor != self.tokens.len() {
|
||||
Err(error(
|
||||
self.span(),
|
||||
"Unexpected content after analysis expression.",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(super) fn open(&mut self) -> Result<(), Error> {
|
||||
self.budget.frame(self.frames + 1, self.span())?;
|
||||
self.frames += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close(&mut self) {
|
||||
self.frames -= 1;
|
||||
}
|
||||
pub(super) fn reference(&mut self) -> Result<Reference, Error> {
|
||||
self.budget.reference(self.span())?;
|
||||
self.name()
|
||||
}
|
||||
pub(super) fn name(&mut self) -> Result<Reference, Error> {
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::Word)
|
||||
|| !crate::valid_alias(self.text())
|
||||
{
|
||||
return Err(error(span, "Expected a valid reference or parameter name."));
|
||||
}
|
||||
let name = self.text().to_owned();
|
||||
self.cursor += 1;
|
||||
Ok(Reference { name, span })
|
||||
}
|
||||
pub(super) fn quoted(&mut self) -> Result<String, Error> {
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::String)
|
||||
{
|
||||
return Err(error(span, "Expected a quoted string."));
|
||||
}
|
||||
let value =
|
||||
serde_json::from_str(self.text()).map_err(|_| error(span, "Invalid quoted string."))?;
|
||||
self.cursor += 1;
|
||||
Ok(value)
|
||||
}
|
||||
pub(super) fn last_end(&self) -> usize {
|
||||
self.tokens[self.cursor - 1].span.end
|
||||
}
|
||||
pub(super) fn exact_literal(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
|
||||
if self.text() == "ratio" {
|
||||
return self.ratio();
|
||||
}
|
||||
let span = self.span();
|
||||
if !self
|
||||
.tokens
|
||||
.get(self.cursor)
|
||||
.is_some_and(|token| token.class == Class::Number)
|
||||
{
|
||||
return Err(error(span, "Expected an exact numeric atom or ratio."));
|
||||
}
|
||||
let value = self
|
||||
.budget
|
||||
.exact(&self.source[span.start..span.end], span)?;
|
||||
self.cursor += 1;
|
||||
Ok((
|
||||
value,
|
||||
LiteralSyntax {
|
||||
span,
|
||||
tokens: vec![span],
|
||||
},
|
||||
))
|
||||
}
|
||||
pub(super) fn integer(&mut self) -> Result<Span, Error> {
|
||||
let span = self.span();
|
||||
let text = self.text();
|
||||
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(error(span, "Expected unsigned integer digits."));
|
||||
}
|
||||
if text.len() > crate::analysis_numeric::MAX_COMPONENT_DIGITS {
|
||||
return Err(error(span, "Integer component exceeds digit limit."));
|
||||
}
|
||||
self.cursor += 1;
|
||||
Ok(span)
|
||||
}
|
||||
pub(super) fn ratio(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
|
||||
let first_token = self.cursor;
|
||||
let start = self.take("ratio")?.start;
|
||||
self.open()?;
|
||||
self.take("(")?;
|
||||
let negative = if self.text() == "-" {
|
||||
self.cursor += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let numerator = self.integer()?;
|
||||
self.take(",")?;
|
||||
let denominator = self.integer()?;
|
||||
let end = self.take(")")?.end;
|
||||
self.close();
|
||||
let n = &self.source[numerator.start..numerator.end];
|
||||
let d = &self.source[denominator.start..denominator.end];
|
||||
let length = 9 + usize::from(negative) + n.len() + d.len();
|
||||
if length > crate::analysis_numeric::MAX_LITERAL_BYTES {
|
||||
return Err(error(
|
||||
Span { start, end },
|
||||
"Numeric reconstruction exceeds literal byte limit.",
|
||||
));
|
||||
}
|
||||
let normalized = format!("ratio({}{n}, {d})", if negative { "-" } else { "" });
|
||||
Ok((
|
||||
self.budget.exact(&normalized, Span { start, end })?,
|
||||
LiteralSyntax {
|
||||
span: Span { start, end },
|
||||
tokens: self.tokens[first_token..self.cursor]
|
||||
.iter()
|
||||
.map(|token| token.span)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,635 +0,0 @@
|
||||
//! Required typed declaration syntax. No model construction or resolution.
|
||||
use super::cursor::Cursor;
|
||||
use super::*;
|
||||
use crate::{Id, Layer};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeclarationKind {
|
||||
QuantityKind,
|
||||
Unit,
|
||||
Calculation,
|
||||
Property(Layer),
|
||||
Check(Layer),
|
||||
Case(Layer),
|
||||
}
|
||||
|
||||
/// Contextual recognition only: never add these words to the global alias ban.
|
||||
pub fn declaration_kind(word: &str) -> Option<DeclarationKind> {
|
||||
use DeclarationKind::*;
|
||||
Some(match word {
|
||||
"quantity_kind" => QuantityKind,
|
||||
"unit" => Unit,
|
||||
"calculation" => Calculation,
|
||||
"oa_property" => Property(Layer::Oa),
|
||||
"sa_property" => Property(Layer::Sa),
|
||||
"oa_check" => Check(Layer::Oa),
|
||||
"sa_check" => Check(Layer::Sa),
|
||||
"oa_analysis" => Case(Layer::Oa),
|
||||
"sa_analysis" => Case(Layer::Sa),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct IdentitySyntax {
|
||||
/// Missing IDs stay unresolved here. The bundle stabilization owner assigns
|
||||
/// and inserts an ID once, then reparses the actual returned bytes.
|
||||
pub value: Option<Id>,
|
||||
pub keyword: Option<Span>,
|
||||
pub literal: Option<Span>,
|
||||
pub insertion: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub kind: DeclarationKind,
|
||||
pub kind_span: Span,
|
||||
pub alias: Reference,
|
||||
pub name: String,
|
||||
pub name_span: Span,
|
||||
pub identity: IdentitySyntax,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Field<T> {
|
||||
/// Complete field including its keyword and trailing semicolon.
|
||||
pub span: Span,
|
||||
pub keyword: Span,
|
||||
/// Same meaning as an ordinary top-level field value span.
|
||||
pub value_span: Span,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ScalarSyntax {
|
||||
pub span: Span,
|
||||
pub kind: ScalarKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ScalarKind {
|
||||
Boolean,
|
||||
Number,
|
||||
Quantity(Reference),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct TypeSyntax {
|
||||
pub span: Span,
|
||||
pub scalar: ScalarSyntax,
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ParameterSyntax {
|
||||
pub span: Span,
|
||||
pub name: Reference,
|
||||
pub identity: IdentitySyntax,
|
||||
pub value_type: TypeSyntax,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct DimensionEntry {
|
||||
pub axis: Span,
|
||||
pub exponent: Span,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct DimensionSyntax {
|
||||
pub exponents: [i16; 7],
|
||||
pub entries: Vec<DimensionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AlgebraSyntax {
|
||||
Linear,
|
||||
Affine(Reference),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ExactSyntax {
|
||||
pub value: Exact,
|
||||
pub literal: LiteralSyntax,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct OverrideSyntax {
|
||||
pub span: Span,
|
||||
pub target: Reference,
|
||||
pub binding: Binding,
|
||||
pub binding_span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Body {
|
||||
QuantityKind {
|
||||
dimension: Field<DimensionSyntax>,
|
||||
algebra: Field<AlgebraSyntax>,
|
||||
},
|
||||
Unit {
|
||||
kind: Field<Reference>,
|
||||
scale: Field<ExactSyntax>,
|
||||
offset: Field<ExactSyntax>,
|
||||
symbol: Field<String>,
|
||||
},
|
||||
Calculation {
|
||||
parameters: Vec<ParameterSyntax>,
|
||||
result: Field<TypeSyntax>,
|
||||
body: Field<Binding>,
|
||||
},
|
||||
Property {
|
||||
subject: Field<Reference>,
|
||||
value_type: Field<TypeSyntax>,
|
||||
base: Field<Binding>,
|
||||
display_unit: Option<Field<Option<Reference>>>,
|
||||
},
|
||||
Check {
|
||||
subject: Option<Field<Option<Reference>>>,
|
||||
documented_constraint: Option<Field<Option<Reference>>>,
|
||||
predicate: Field<Binding>,
|
||||
},
|
||||
Case {
|
||||
subject: Option<Field<Option<Reference>>>,
|
||||
base: Field<()>,
|
||||
overrides: Field<Vec<OverrideSyntax>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Declaration {
|
||||
pub header: Header,
|
||||
pub span: Span,
|
||||
pub body_span: Span,
|
||||
pub description: Option<Field<String>>,
|
||||
/// Analysis roots may explicitly spell `parent none`; definitions may not.
|
||||
pub parent: Option<Field<()>>,
|
||||
pub body: Body,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
dimension: Option<Field<DimensionSyntax>>,
|
||||
algebra: Option<Field<AlgebraSyntax>>,
|
||||
kind: Option<Field<Reference>>,
|
||||
scale: Option<Field<ExactSyntax>>,
|
||||
offset: Option<Field<ExactSyntax>>,
|
||||
symbol: Option<Field<String>>,
|
||||
result: Option<Field<TypeSyntax>>,
|
||||
body: Option<Field<Binding>>,
|
||||
subject: Option<Field<Reference>>,
|
||||
optional_subject: Option<Field<Option<Reference>>>,
|
||||
value_type: Option<Field<TypeSyntax>>,
|
||||
base: Option<Field<Binding>>,
|
||||
display_unit: Option<Field<Option<Reference>>>,
|
||||
documented_constraint: Option<Field<Option<Reference>>>,
|
||||
predicate: Option<Field<Binding>>,
|
||||
baseline: Option<Field<()>>,
|
||||
overrides: Option<Field<Vec<OverrideSyntax>>>,
|
||||
}
|
||||
|
||||
fn required<T>(value: Option<T>, name: &str, at: Span) -> Result<T, Error> {
|
||||
value.ok_or_else(|| error(at, format!("Missing required field '{name}'.")))
|
||||
}
|
||||
|
||||
impl Fields {
|
||||
fn finish(
|
||||
self,
|
||||
kind: DeclarationKind,
|
||||
parameters: Vec<ParameterSyntax>,
|
||||
at: Span,
|
||||
) -> Result<Body, Error> {
|
||||
Ok(match kind {
|
||||
DeclarationKind::QuantityKind => Body::QuantityKind {
|
||||
dimension: required(self.dimension, "dimension", at)?,
|
||||
algebra: required(self.algebra, "algebra", at)?,
|
||||
},
|
||||
DeclarationKind::Unit => Body::Unit {
|
||||
kind: required(self.kind, "kind", at)?,
|
||||
scale: required(self.scale, "scale", at)?,
|
||||
offset: required(self.offset, "offset", at)?,
|
||||
symbol: required(self.symbol, "symbol", at)?,
|
||||
},
|
||||
DeclarationKind::Calculation => Body::Calculation {
|
||||
parameters,
|
||||
result: required(self.result, "result", at)?,
|
||||
body: required(self.body, "body", at)?,
|
||||
},
|
||||
DeclarationKind::Property(_) => Body::Property {
|
||||
subject: required(self.subject, "subject", at)?,
|
||||
value_type: required(self.value_type, "type", at)?,
|
||||
base: required(self.base, "base", at)?,
|
||||
display_unit: self.display_unit,
|
||||
},
|
||||
DeclarationKind::Check(_) => Body::Check {
|
||||
subject: self.optional_subject,
|
||||
documented_constraint: self.documented_constraint,
|
||||
predicate: required(self.predicate, "predicate", at)?,
|
||||
},
|
||||
DeclarationKind::Case(_) => Body::Case {
|
||||
subject: self.optional_subject,
|
||||
base: required(self.baseline, "base", at)?,
|
||||
overrides: required(self.overrides, "overrides", at)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume exactly one declaration from already charged full-file tokens.
|
||||
/// `version` is inherited from the caller's entry policy; fragments have no
|
||||
/// independent version header. Public parsing does not call this staged API.
|
||||
pub(super) fn parse(
|
||||
p: &mut Cursor<'_, '_>,
|
||||
version: u32,
|
||||
lexical_owner: Option<Id>,
|
||||
) -> Result<Declaration, Error> {
|
||||
p.budget.active()?;
|
||||
let kind_span = p.span();
|
||||
let kind = declaration_kind(p.text())
|
||||
.ok_or_else(|| error(kind_span, "Expected a short typed declaration word."))?;
|
||||
if version != 8 {
|
||||
return Err(error(kind_span, "Analysis declarations require source8."));
|
||||
}
|
||||
if lexical_owner.is_some() {
|
||||
return Err(error(
|
||||
kind_span,
|
||||
"Analysis declarations and definitions must be roots.",
|
||||
));
|
||||
}
|
||||
p.budget.entry(kind_span)?;
|
||||
p.cursor += 1;
|
||||
let alias = p.name()?;
|
||||
let name_span = p.span();
|
||||
let name = p.quoted()?;
|
||||
let identity = p.identity()?;
|
||||
p.open()?;
|
||||
let opening = p.take("{")?;
|
||||
let header = Header {
|
||||
kind,
|
||||
kind_span,
|
||||
alias,
|
||||
name,
|
||||
name_span,
|
||||
identity,
|
||||
span: Span {
|
||||
start: kind_span.start,
|
||||
end: opening.start,
|
||||
},
|
||||
};
|
||||
let mut fields = Fields::default();
|
||||
let mut description = None;
|
||||
let mut parent = None;
|
||||
let mut parameters = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut parameter_names = BTreeSet::new();
|
||||
let mut parameter_ids = BTreeSet::new();
|
||||
while p.text() != "}" {
|
||||
let keyword = p.span();
|
||||
p.budget.entry(keyword)?;
|
||||
if p.text() == "input" && kind == DeclarationKind::Calculation {
|
||||
let parameter = p.parameter()?;
|
||||
if !parameter_names.insert(parameter.name.name.clone()) {
|
||||
return Err(error(parameter.name.span, "Duplicate parameter name."));
|
||||
}
|
||||
if let Some(id) = parameter.identity.value
|
||||
&& !parameter_ids.insert(id)
|
||||
{
|
||||
return Err(error(
|
||||
parameter.identity.literal.expect("explicit identity"),
|
||||
"Duplicate parameter identity.",
|
||||
));
|
||||
}
|
||||
parameters.push(parameter);
|
||||
continue;
|
||||
}
|
||||
// Borrow the original spelling for duplicate detection; no field-name
|
||||
// allocation and no map insertion can overwrite an earlier value.
|
||||
let source = p.source;
|
||||
let key = &source[keyword.start..keyword.end];
|
||||
if !seen.insert(key) {
|
||||
return Err(error(keyword, format!("Duplicate field '{key}'.")));
|
||||
}
|
||||
p.cursor += 1;
|
||||
use DeclarationKind::*;
|
||||
match (kind, key) {
|
||||
(_, "description") => description = Some(p.field(keyword, |p| p.quoted())?),
|
||||
(Property(_) | Check(_) | Case(_), "parent") => {
|
||||
parent = Some(p.field(keyword, |p| {
|
||||
p.take("none")?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
(QuantityKind, "dimension") => {
|
||||
fields.dimension = Some(p.field(keyword, |p| p.dimension())?)
|
||||
}
|
||||
(QuantityKind, "algebra") => fields.algebra = Some(p.field(keyword, |p| p.algebra())?),
|
||||
(Unit, "kind") => fields.kind = Some(p.field(keyword, |p| p.at_reference())?),
|
||||
(Unit, "scale") => fields.scale = Some(p.field(keyword, |p| p.exact_value())?),
|
||||
(Unit, "offset") => fields.offset = Some(p.field(keyword, |p| p.exact_value())?),
|
||||
(Unit, "symbol") => fields.symbol = Some(p.field(keyword, |p| p.quoted())?),
|
||||
(Calculation, "result") => fields.result = Some(p.field(keyword, |p| p.value_type())?),
|
||||
(Calculation, "body") => fields.body = Some(p.field(keyword, syntax::binding)?),
|
||||
(Property(_), "subject") => {
|
||||
fields.subject = Some(p.field(keyword, |p| p.at_reference())?)
|
||||
}
|
||||
(Property(_), "type") => {
|
||||
fields.value_type = Some(p.field(keyword, |p| p.value_type())?)
|
||||
}
|
||||
(Property(_), "base") => fields.base = Some(p.field(keyword, syntax::binding)?),
|
||||
(Property(_), "display_unit") => {
|
||||
fields.display_unit = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_) | Case(_), "subject") => {
|
||||
fields.optional_subject = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_), "documented_constraint") => {
|
||||
fields.documented_constraint = Some(p.field(keyword, |p| p.optional_reference())?)
|
||||
}
|
||||
(Check(_), "predicate") => fields.predicate = Some(p.field(keyword, syntax::binding)?),
|
||||
(Case(_), "base") => {
|
||||
fields.baseline = Some(p.field(keyword, |p| {
|
||||
p.take("ModelBaseline")?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
(Case(_), "overrides") => fields.overrides = Some(p.field(keyword, |p| p.overrides())?),
|
||||
_ => {
|
||||
return Err(error(
|
||||
keyword,
|
||||
format!("Unknown field '{key}' for this typed declaration."),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let closing = p.take("}")?;
|
||||
p.close();
|
||||
let body = fields.finish(kind, parameters, closing)?;
|
||||
Ok(Declaration {
|
||||
header,
|
||||
span: Span {
|
||||
start: kind_span.start,
|
||||
end: closing.end,
|
||||
},
|
||||
body_span: Span {
|
||||
start: opening.end,
|
||||
end: closing.start,
|
||||
},
|
||||
description,
|
||||
parent,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
fn field<T>(
|
||||
&mut self,
|
||||
keyword: Span,
|
||||
parse: impl FnOnce(&mut Self) -> Result<T, Error>,
|
||||
) -> Result<Field<T>, Error> {
|
||||
let start = self.span().start;
|
||||
let value = parse(self)?;
|
||||
let end = self.last_end();
|
||||
let terminator = self.take(";")?;
|
||||
Ok(Field {
|
||||
span: Span {
|
||||
start: keyword.start,
|
||||
end: terminator.end,
|
||||
},
|
||||
keyword,
|
||||
value_span: Span { start, end },
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn identity(&mut self) -> Result<IdentitySyntax, Error> {
|
||||
if self.text() != "id" {
|
||||
return Ok(IdentitySyntax {
|
||||
value: None,
|
||||
keyword: None,
|
||||
literal: None,
|
||||
insertion: Some(self.span().start),
|
||||
});
|
||||
}
|
||||
let keyword = self.take("id")?;
|
||||
let literal = self.span();
|
||||
let spelling = self.quoted()?;
|
||||
if spelling.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|at| spelling.as_bytes()[at] == b'-')
|
||||
{
|
||||
return Err(error(
|
||||
literal,
|
||||
"Expected UUID in 8-4-4-4-12 hexadecimal form.",
|
||||
));
|
||||
}
|
||||
let id = Id::parse_str(&spelling).map_err(|_| error(literal, "Invalid UUID identity."))?;
|
||||
Ok(IdentitySyntax {
|
||||
value: Some(id),
|
||||
keyword: Some(keyword),
|
||||
literal: Some(literal),
|
||||
insertion: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parameter(&mut self) -> Result<ParameterSyntax, Error> {
|
||||
let start = self.take("input")?.start;
|
||||
let name = self.name()?;
|
||||
let identity = self.identity()?;
|
||||
self.take(":")?;
|
||||
let value_type = self.value_type()?;
|
||||
let end = self.take(";")?.end;
|
||||
Ok(ParameterSyntax {
|
||||
span: Span { start, end },
|
||||
name,
|
||||
identity,
|
||||
value_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn at_reference(&mut self) -> Result<Reference, Error> {
|
||||
self.take("@")?;
|
||||
self.reference()
|
||||
}
|
||||
|
||||
fn optional_reference(&mut self) -> Result<Option<Reference>, Error> {
|
||||
if self.text() == "none" {
|
||||
self.cursor += 1;
|
||||
Ok(None)
|
||||
} else {
|
||||
self.at_reference().map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_value(&mut self) -> Result<ExactSyntax, Error> {
|
||||
let (value, literal) = self.exact_literal()?;
|
||||
Ok(ExactSyntax { value, literal })
|
||||
}
|
||||
|
||||
fn value_type(&mut self) -> Result<TypeSyntax, Error> {
|
||||
let start = self.span().start;
|
||||
self.budget.entry(self.span())?;
|
||||
let list = self.text() == "List";
|
||||
if list {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("(")?;
|
||||
}
|
||||
let scalar = self.scalar_type()?;
|
||||
if list {
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
}
|
||||
Ok(TypeSyntax {
|
||||
span: Span {
|
||||
start,
|
||||
end: self.last_end(),
|
||||
},
|
||||
scalar,
|
||||
list,
|
||||
})
|
||||
}
|
||||
|
||||
fn scalar_type(&mut self) -> Result<ScalarSyntax, Error> {
|
||||
let start = self.span().start;
|
||||
let kind = match self.text() {
|
||||
"Boolean" => {
|
||||
self.cursor += 1;
|
||||
ScalarKind::Boolean
|
||||
}
|
||||
"Number" => {
|
||||
self.cursor += 1;
|
||||
ScalarKind::Number
|
||||
}
|
||||
"Quantity" => {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("(")?;
|
||||
let reference = self.at_reference()?;
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
ScalarKind::Quantity(reference)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
self.span(),
|
||||
"Expected Boolean, Number or Quantity(@kind); nested List is not a scalar type.",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(ScalarSyntax {
|
||||
span: Span {
|
||||
start,
|
||||
end: self.last_end(),
|
||||
},
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn dimension(&mut self) -> Result<DimensionSyntax, Error> {
|
||||
self.open()?;
|
||||
self.take("{")?;
|
||||
let mut exponents = [0; 7];
|
||||
let mut seen = [false; 7];
|
||||
let mut entries = Vec::new();
|
||||
while self.text() != "}" {
|
||||
let axis = self.span();
|
||||
self.budget.entry(axis)?;
|
||||
let index = match self.text() {
|
||||
"length" => 0,
|
||||
"mass" => 1,
|
||||
"time" => 2,
|
||||
"electric_current" => 3,
|
||||
"temperature" => 4,
|
||||
"amount" => 5,
|
||||
"luminous_intensity" => 6,
|
||||
_ => return Err(error(axis, "Unknown dimension axis.")),
|
||||
};
|
||||
if seen[index] {
|
||||
return Err(error(axis, "Duplicate dimension axis."));
|
||||
}
|
||||
seen[index] = true;
|
||||
self.cursor += 1;
|
||||
let start = self.span().start;
|
||||
let negative = self.text() == "-";
|
||||
if negative {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let digits = self.integer()?;
|
||||
let value = self.source[digits.start..digits.end]
|
||||
.parse::<i16>()
|
||||
.map_err(|_| error(digits, "Dimension exponent exceeds -32..32."))?;
|
||||
if value > 32 {
|
||||
return Err(error(digits, "Dimension exponent exceeds -32..32."));
|
||||
}
|
||||
exponents[index] = if negative { -value } else { value };
|
||||
let exponent = Span {
|
||||
start,
|
||||
end: digits.end,
|
||||
};
|
||||
let end = self.take(";")?.end;
|
||||
entries.push(DimensionEntry {
|
||||
axis,
|
||||
exponent,
|
||||
span: Span {
|
||||
start: axis.start,
|
||||
end,
|
||||
},
|
||||
});
|
||||
}
|
||||
self.take("}")?;
|
||||
self.close();
|
||||
Ok(DimensionSyntax { exponents, entries })
|
||||
}
|
||||
|
||||
fn algebra(&mut self) -> Result<AlgebraSyntax, Error> {
|
||||
if self.text() == "Linear" {
|
||||
self.cursor += 1;
|
||||
return Ok(AlgebraSyntax::Linear);
|
||||
}
|
||||
self.take("Affine")?;
|
||||
self.open()?;
|
||||
self.take("(")?;
|
||||
let difference = self.at_reference()?;
|
||||
self.take(")")?;
|
||||
self.close();
|
||||
Ok(AlgebraSyntax::Affine(difference))
|
||||
}
|
||||
|
||||
fn overrides(&mut self) -> Result<Vec<OverrideSyntax>, Error> {
|
||||
self.open()?;
|
||||
self.take("{")?;
|
||||
let mut entries = Vec::new();
|
||||
let mut names = BTreeSet::new();
|
||||
while self.text() != "}" {
|
||||
let start = self.span().start;
|
||||
self.budget.entry(self.span())?;
|
||||
let target = self.at_reference()?;
|
||||
if !names.insert(target.name.clone()) {
|
||||
return Err(error(target.span, "Duplicate override target."));
|
||||
}
|
||||
self.take("=")?;
|
||||
let binding_start = self.span().start;
|
||||
let binding = syntax::binding(self)?;
|
||||
let binding_span = Span {
|
||||
start: binding_start,
|
||||
end: self.last_end(),
|
||||
};
|
||||
let end = self.take(";")?.end;
|
||||
entries.push(OverrideSyntax {
|
||||
span: Span { start, end },
|
||||
target,
|
||||
binding,
|
||||
binding_span,
|
||||
});
|
||||
}
|
||||
self.take("}")?;
|
||||
self.close();
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,613 +0,0 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use crate::language::analysis_source::budget::Limits;
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn budget() -> Budget {
|
||||
Budget::new(limits()).unwrap()
|
||||
}
|
||||
|
||||
fn declaration(source: &str) -> Declaration {
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut budget);
|
||||
let result = parse(&mut cursor, 8, None).unwrap();
|
||||
cursor.end().unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
fn failure(source: &str) -> Error {
|
||||
let mut budget = budget();
|
||||
let tokens = lex::tokens(source, &mut budget).unwrap();
|
||||
parse(&mut Cursor::new(source, &tokens, &mut budget), 8, None).unwrap_err()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_declarations_have_independent_typed_body_and_header_oracles() {
|
||||
let source = "quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" { dimension { time 1; length -2; }; algebra Affine(@difference); description \"雪\"; }";
|
||||
let actual = declaration(source);
|
||||
assert_eq!(actual.header.kind, DeclarationKind::QuantityKind);
|
||||
assert_eq!(actual.header.alias.name, "duration");
|
||||
assert_eq!(actual.header.name, "Duration");
|
||||
assert_eq!(
|
||||
actual.header.identity.value,
|
||||
Some(Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap())
|
||||
);
|
||||
assert_eq!(actual.description.as_ref().unwrap().value, "雪");
|
||||
assert!(actual.parent.is_none());
|
||||
let Body::QuantityKind { dimension, algebra } = actual.body else {
|
||||
panic!("quantity kind")
|
||||
};
|
||||
assert_eq!(dimension.value.exponents, [-2, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(text(source, dimension.keyword), "dimension");
|
||||
assert_eq!(text(source, dimension.value_span), "{ time 1; length -2; }");
|
||||
assert_eq!(text(source, dimension.value.entries[1].exponent), "-2");
|
||||
let AlgebraSyntax::Affine(difference) = algebra.value else {
|
||||
panic!("affine")
|
||||
};
|
||||
assert_eq!(difference.name, "difference");
|
||||
|
||||
let Body::Unit { kind, scale, offset, symbol } = declaration("unit millisecond \"Millisecond\" { kind @duration; scale ratio (1, // divisor\n 1000); offset 0; symbol \"ms\"; }").body else { panic!("unit") };
|
||||
assert_eq!(kind.value.name, "duration");
|
||||
assert_eq!(scale.value.value.canonical_literal(), "ratio(1, 1000)");
|
||||
assert_eq!(offset.value.value.canonical_literal(), "0");
|
||||
assert_eq!(symbol.value, "ms");
|
||||
|
||||
let Body::Calculation { parameters, result, body } = declaration("calculation combine \"Combine durations\" { input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration); input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration); result Quantity(@duration); body $a + $b; }").body else { panic!("calculation") };
|
||||
assert_eq!(
|
||||
parameters
|
||||
.iter()
|
||||
.map(|p| p.name.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a", "b"]
|
||||
);
|
||||
assert!(!result.value.list);
|
||||
assert!(
|
||||
matches!(result.value.scalar.kind, ScalarKind::Quantity(ref r) if r.name == "duration")
|
||||
);
|
||||
let Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary { op, left, right },
|
||||
..
|
||||
}) = body.value
|
||||
else {
|
||||
panic!("binary")
|
||||
};
|
||||
assert_eq!(op, BinaryOp::Add);
|
||||
assert!(matches!(left.kind, ExpressionKind::Parameter(ref r) if r.name == "a"));
|
||||
assert!(matches!(right.kind, ExpressionKind::Parameter(ref r) if r.name == "b"));
|
||||
|
||||
for (word, layer) in [("oa_property", Layer::Oa), ("sa_property", Layer::Sa)] {
|
||||
let actual = declaration(&format!(
|
||||
"{word} receive_time \"Receive time\" {{ subject @receive; type Quantity(@duration); base 750 [@millisecond]; display_unit @millisecond; parent none; }}"
|
||||
));
|
||||
assert_eq!(actual.header.kind, DeclarationKind::Property(layer));
|
||||
assert!(actual.parent.is_some());
|
||||
let Body::Property {
|
||||
subject,
|
||||
value_type,
|
||||
base,
|
||||
display_unit,
|
||||
} = actual.body
|
||||
else {
|
||||
panic!("property")
|
||||
};
|
||||
assert_eq!(subject.value.name, "receive");
|
||||
assert!(matches!(
|
||||
value_type.value.scalar.kind,
|
||||
ScalarKind::Quantity(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
base.value,
|
||||
Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Quantity { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(display_unit.unwrap().value.unwrap().name, "millisecond");
|
||||
}
|
||||
for word in ["oa_check", "sa_check"] {
|
||||
let Body::Check { subject, documented_constraint, predicate } = declaration(&format!("{word} budget \"Response budget\" {{ subject none; documented_constraint @constraint; predicate @total_time <= @limit; }}")).body else { panic!("check") };
|
||||
assert!(subject.unwrap().value.is_none());
|
||||
assert_eq!(
|
||||
documented_constraint.unwrap().value.unwrap().name,
|
||||
"constraint"
|
||||
);
|
||||
assert!(matches!(
|
||||
predicate.value,
|
||||
Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary {
|
||||
op: BinaryOp::LessOrEqual,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
for word in ["oa_analysis", "sa_analysis"] {
|
||||
let Body::Case { subject, base, overrides } = declaration(&format!("{word} degraded \"Degraded radio\" {{ subject @respond; base ModelBaseline; overrides {{ @receive_time = unbound; }}; }}")).body else { panic!("case") };
|
||||
assert_eq!(subject.unwrap().value.unwrap().name, "respond");
|
||||
assert!(base.value_span.end > base.value_span.start);
|
||||
assert_eq!(overrides.value.len(), 1);
|
||||
assert_eq!(overrides.value[0].target.name, "receive_time");
|
||||
assert!(matches!(overrides.value[0].binding, Binding::Unbound(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_unbound_and_all_scalar_list_types_are_explicit_valid_syntax() {
|
||||
for (spelling, list, kind) in [
|
||||
("Boolean", false, 0),
|
||||
("Number", false, 1),
|
||||
("Quantity(@duration)", false, 2),
|
||||
("List(Boolean)", true, 0),
|
||||
("List(Number)", true, 1),
|
||||
("List(Quantity(@duration))", true, 2),
|
||||
] {
|
||||
let Body::Calculation {
|
||||
parameters,
|
||||
result,
|
||||
body,
|
||||
} = declaration(&format!(
|
||||
"calculation c \"C\" {{ result {spelling}; body unbound; }}"
|
||||
))
|
||||
.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(parameters.is_empty());
|
||||
assert_eq!(result.value.list, list);
|
||||
assert_eq!(
|
||||
match result.value.scalar.kind {
|
||||
ScalarKind::Boolean => 0,
|
||||
ScalarKind::Number => 1,
|
||||
ScalarKind::Quantity(_) => 2,
|
||||
},
|
||||
kind
|
||||
);
|
||||
assert!(matches!(body.value, Binding::Unbound(_)));
|
||||
}
|
||||
let Body::QuantityKind { dimension, algebra } =
|
||||
declaration("quantity_kind q \"Q\" { dimension {}; algebra Linear; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(dimension.value.exponents, [0; 7]);
|
||||
assert!(dimension.value.entries.is_empty());
|
||||
assert_eq!(algebra.value, AlgebraSyntax::Linear);
|
||||
let Body::Case {
|
||||
subject, overrides, ..
|
||||
} = declaration("oa_analysis c \"C\" { base ModelBaseline; overrides {}; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(subject.is_none());
|
||||
assert!(overrides.value.is_empty());
|
||||
let Body::Check {
|
||||
subject,
|
||||
documented_constraint,
|
||||
..
|
||||
} = declaration("oa_check c \"C\" { predicate unbound; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(subject.is_none() && documented_constraint.is_none());
|
||||
let Body::Property { display_unit, .. } =
|
||||
declaration("oa_property p \"P\" { subject @s; type Number; base unbound; }").body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(display_unit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_fields_and_unknown_or_duplicate_entries_reject_locally() {
|
||||
let required_sets: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"quantity_kind q \"Q\"",
|
||||
&["dimension {};", "algebra Linear;"],
|
||||
),
|
||||
(
|
||||
"unit u \"U\"",
|
||||
&["kind @q;", "scale 1;", "offset 0;", "symbol \"u\";"],
|
||||
),
|
||||
("calculation c \"C\"", &["result Number;", "body unbound;"]),
|
||||
(
|
||||
"oa_property p \"P\"",
|
||||
&["subject @s;", "type Number;", "base unbound;"],
|
||||
),
|
||||
("oa_check c \"C\"", &["predicate unbound;"]),
|
||||
(
|
||||
"oa_analysis a \"A\"",
|
||||
&["base ModelBaseline;", "overrides {};"],
|
||||
),
|
||||
];
|
||||
for (header, fields) in required_sets {
|
||||
declaration(&format!("{header} {{ {} }}", fields.join(" ")));
|
||||
for removed in 0..fields.len() {
|
||||
let remaining = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, field)| (i != removed).then_some(*field))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let error = failure(&format!("{header} {{ {remaining} }}"));
|
||||
assert!(
|
||||
error.message.contains("Missing required field"),
|
||||
"{error:?}"
|
||||
);
|
||||
let duplicate = format!("{header} {{ {} {} }}", fields.join(" "), fields[removed]);
|
||||
assert!(failure(&duplicate).message.contains("Duplicate field"));
|
||||
}
|
||||
}
|
||||
for source in [
|
||||
"quantity_kind q \"Q\" { dimension { time 1; time 2; }; algebra Linear; }",
|
||||
"quantity_kind q \"Q\" { dimension { unknown 1; }; algebra Linear; }",
|
||||
"quantity_kind q \"Q\" { dimension { time 33; }; algebra Linear; }",
|
||||
"unit u \"U\" { kind @q; scale 1 + 2; offset 0; symbol \"u\"; }",
|
||||
"unit u \"U\" { kind @q; scale 1 [@u]; offset 0; symbol \"u\"; }",
|
||||
"unit u \"U\" { kind @q; scale ratio(1, 0); offset 0; symbol \"u\"; }",
|
||||
"calculation c \"C\" { result List(List(Number)); body unbound; }",
|
||||
"calculation c \"C\" { input x : Number; input x : Number; result Number; body unbound; }",
|
||||
"calculation c \"C\" { input x id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; input y id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; result Number; body unbound; }",
|
||||
"oa_property p \"P\" { subject none; type Number; base unbound; }",
|
||||
"oa_property p \"P\" { subject @s; type Number; base none; }",
|
||||
"oa_property p \"P\" { subject @s; type Number; base unbound; parent @s; }",
|
||||
"oa_analysis a \"A\" { base @another; overrides {}; }",
|
||||
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = clear; }; }",
|
||||
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }",
|
||||
"oa_check c \"C\" { predicate @f(x: 1, x: 2); }",
|
||||
"quantity_kind q \"Q\" { dimension {}; algebra Linear; parent none; }",
|
||||
"oa_check c \"C\" { predicate unbound; invented none; }",
|
||||
] {
|
||||
failure(source);
|
||||
}
|
||||
let duplicate = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }";
|
||||
let error = failure(duplicate);
|
||||
assert_eq!(text(duplicate, error.span), "p");
|
||||
assert!(error.span.start > duplicate.find("@p").unwrap() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_cursor_preserves_full_file_spans_and_does_not_rescan_fields() {
|
||||
let source = "// 雪\r\ncalculation c \"C\" { input a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a); }\r\nunit u \"U\" { kind @q; scale 1; offset 0; symbol \"u\"; }";
|
||||
let mut shared = budget();
|
||||
let tokens = lex::tokens(source, &mut shared).unwrap();
|
||||
let lexical = shared.usage;
|
||||
let mut cursor = Cursor::new(source, &tokens, &mut shared);
|
||||
let first = parse(&mut cursor, 8, None).unwrap();
|
||||
assert_eq!(cursor.text(), "unit");
|
||||
let first_end = first.span.end;
|
||||
let second = parse(&mut cursor, 8, None).unwrap();
|
||||
cursor.end().unwrap();
|
||||
assert!(second.span.start > first_end);
|
||||
assert_eq!(text(source, first.header.kind_span), "calculation");
|
||||
assert_eq!(text(source, first.header.name_span), "\"C\"");
|
||||
assert_eq!(text(source, second.header.alias.span), "u");
|
||||
assert_eq!(
|
||||
text(source, first.body_span).trim().strip_prefix("input"),
|
||||
Some(
|
||||
" a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a);"
|
||||
)
|
||||
);
|
||||
let Body::Calculation {
|
||||
parameters, body, ..
|
||||
} = first.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(text(source, parameters[0].span), "input a : Number;");
|
||||
assert_eq!(text(source, parameters[0].value_type.span), "Number");
|
||||
let Binding::Expression(Expression {
|
||||
kind: ExpressionKind::Binary { left, right, .. },
|
||||
..
|
||||
}) = body.value
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
arguments: left, ..
|
||||
} = left.kind
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let ExpressionKind::Call {
|
||||
arguments: right, ..
|
||||
} = right.kind
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let spans = [
|
||||
left[0].parameter.span,
|
||||
left[1].parameter.span,
|
||||
right[0].parameter.span,
|
||||
right[1].parameter.span,
|
||||
];
|
||||
assert_eq!(spans.map(|span| text(source, span)), ["a", "b", "a", "b"]);
|
||||
assert!(spans.windows(2).all(|pair| pair[0].end < pair[1].start));
|
||||
let literal = left[0].value.literal.as_ref().unwrap();
|
||||
assert_eq!(text(source, literal.span), "ratio (1, // first\r\n 2)");
|
||||
assert_eq!(
|
||||
literal
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|span| text(source, *span))
|
||||
.collect::<Vec<_>>(),
|
||||
["ratio", "(", "1", ",", "2", ")"]
|
||||
);
|
||||
assert_eq!(shared.usage.bytes, source.len());
|
||||
assert_eq!(shared.usage.bytes, lexical.bytes);
|
||||
assert_eq!(shared.usage.tokens, lexical.tokens);
|
||||
assert_eq!(shared.usage.references, 9); // two calls, four keys, two $a, unit kind.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_metadata_is_stable_and_parameter_ids_are_owner_scoped() {
|
||||
let source =
|
||||
"calculation c \"C\" { input a // identity here\r\n : Number; result Number; body $a; }";
|
||||
let first = declaration(source);
|
||||
let second = declaration(source);
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
first.header.identity.insertion,
|
||||
Some(source.find('{').unwrap())
|
||||
);
|
||||
let Body::Calculation { parameters, .. } = first.body else {
|
||||
panic!()
|
||||
};
|
||||
let id = ¶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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,630 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//! Source8 significant-token adapter over the shared raw scanner.
|
||||
use super::super::raw_tokens::{Class as RawClass, Scanner};
|
||||
use super::{Budget, Error, Span, error};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
Word,
|
||||
Number,
|
||||
String,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
pub class: Class,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
pub fn tokens(source: &str, budget: &mut Budget) -> Result<Vec<Token>, Error> {
|
||||
budget.bytes(
|
||||
source.len(),
|
||||
Span {
|
||||
start: 0,
|
||||
end: source.len(),
|
||||
},
|
||||
)?;
|
||||
let mut scanner = Scanner::new(source);
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < source.len() {
|
||||
// Charge trivia too, before the scanner advances or a token is stored.
|
||||
budget.token(Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
})?;
|
||||
let token = scanner.next().expect("source remains");
|
||||
offset = token.span.end;
|
||||
let class = match token.class {
|
||||
RawClass::Word => Class::Word,
|
||||
RawClass::Number => Class::Number,
|
||||
RawClass::String => Class::String,
|
||||
RawClass::Punctuation => Class::Punctuation,
|
||||
RawClass::Whitespace | RawClass::Comment => continue,
|
||||
RawClass::Error => return Err(error(token.span, "Invalid source token.")),
|
||||
};
|
||||
result.push(Token {
|
||||
class,
|
||||
span: token.span,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,961 +0,0 @@
|
||||
//! Staged typed resolution. The caller owns parsing, ID stabilization and the
|
||||
//! single complete-Project semantic admission after merging these additions.
|
||||
use super::declarations::{self as d, Declaration, DeclarationKind};
|
||||
use super::*;
|
||||
use crate::analysis as a;
|
||||
use crate::analysis_numeric::Dimension;
|
||||
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Caller-assigned file key; spans are original offsets within that file.
|
||||
pub(super) struct Unit {
|
||||
pub file: usize,
|
||||
pub declarations: Vec<Declaration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Failure {
|
||||
/// None denotes an invalid ordinary skeleton, before typed resolution.
|
||||
pub file: Option<usize>,
|
||||
pub error: Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Category {
|
||||
Project,
|
||||
Element(Kind, Layer),
|
||||
Relation,
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
QuantityKind,
|
||||
Unit,
|
||||
Calculation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, 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)]
|
||||
pub(super) 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(())
|
||||
}
|
||||
pub(super) 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(())
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
lower_bodies(
|
||||
&tables,
|
||||
units
|
||||
.iter()
|
||||
.flat_map(|unit| unit.declarations.iter().map(move |d| (unit.file, d))),
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
/// Catalog and borrowed declarations must be from the same private witness.
|
||||
/// The mixed caller reserves aggregate live storage before entering this body;
|
||||
/// the legacy wrapper retains its original individual phase checks.
|
||||
pub(super) fn lower_bodies<'a>(
|
||||
tables: &Tables,
|
||||
declarations: impl IntoIterator<Item = (usize, &'a Declaration)>,
|
||||
budget: &Budget,
|
||||
) -> Result<Lowered, Failure> {
|
||||
budget
|
||||
.active()
|
||||
.map_err(|error| Failure { file: None, error })?;
|
||||
let mut output = Lowered {
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
occurrences: Vec::new(),
|
||||
expressions: Vec::new(),
|
||||
};
|
||||
for (file, declaration) in declarations {
|
||||
let owner = declaration
|
||||
.header
|
||||
.identity
|
||||
.value
|
||||
.expect("collected stable identity");
|
||||
let mut resolver = Resolver {
|
||||
tables,
|
||||
output: &mut output,
|
||||
budget,
|
||||
file,
|
||||
owner,
|
||||
};
|
||||
resolver.declaration(declaration).map_err(|error| Failure {
|
||||
file: Some(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;
|
||||
@@ -1,7 +0,0 @@
|
||||
arcadia 7;
|
||||
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
|
||||
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
|
||||
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
arcadia 7;
|
||||
project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
|
||||
sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {}
|
||||
sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
|
||||
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
|
||||
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
|
||||
subject @receive; type Quantity(@duration); display_unit @millisecond;
|
||||
base 750 [@millisecond];
|
||||
}
|
||||
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
|
||||
subject @dispatch; type Quantity(@duration); display_unit @second;
|
||||
base 1 [@second];
|
||||
}
|
||||
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base @combine(a: @receive_time, b: @dispatch_time);
|
||||
}
|
||||
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base 2 [@second];
|
||||
}
|
||||
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
|
||||
subject none; documented_constraint none; predicate @total_time <= @limit;
|
||||
}
|
||||
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = 1250 [@millisecond]; };
|
||||
}
|
||||
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = unbound; };
|
||||
}
|
||||
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}
|
||||
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
|
||||
kind @duration; scale 1; offset 0; symbol "s";
|
||||
}
|
||||
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
|
||||
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
|
||||
}
|
||||
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time 1; }; algebra Linear;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
|
||||
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
|
||||
sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" {
|
||||
subject @receive; type Quantity(@duration); display_unit @millisecond;
|
||||
base 750 [@millisecond];
|
||||
}
|
||||
sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" {
|
||||
subject @dispatch; type Quantity(@duration); display_unit @second;
|
||||
base 1 [@second];
|
||||
}
|
||||
sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base @combine(a: @receive_time, b: @dispatch_time);
|
||||
}
|
||||
sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base 2 [@second];
|
||||
}
|
||||
sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" {
|
||||
subject none; documented_constraint none; predicate @total_time <= @limit;
|
||||
}
|
||||
sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = 1250 [@millisecond]; };
|
||||
}
|
||||
sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = unbound; };
|
||||
}
|
||||
calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}
|
||||
unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
|
||||
kind @duration; scale 1; offset 0; symbol "s";
|
||||
}
|
||||
unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" {
|
||||
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
|
||||
}
|
||||
quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time 1; }; algebra Linear;
|
||||
}
|
||||
@@ -1,888 +0,0 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use crate::language::analysis_source::{budget::Limits, cursor::Cursor};
|
||||
|
||||
use super::super::model_oracle as oracle;
|
||||
|
||||
const OA: &str = include_str!("fixtures/typed-oa.arcadia");
|
||||
const SA: &str = include_str!("fixtures/typed-sa.arcadia");
|
||||
const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia");
|
||||
const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia");
|
||||
|
||||
fn budget() -> Budget {
|
||||
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||
}
|
||||
fn id(text: &str) -> Id {
|
||||
Id::parse_str(text).unwrap()
|
||||
}
|
||||
fn by_alias(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn ordinary(source: &str) -> Project {
|
||||
crate::language::parse(source).unwrap()
|
||||
}
|
||||
fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit {
|
||||
let tokens = lex::tokens(source, budget).unwrap();
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut declarations = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
declarations.push(d::parse(&mut cursor, 8, None).unwrap());
|
||||
}
|
||||
Unit { file, declarations }
|
||||
}
|
||||
fn merge(mut ordinary: Project, lowered: Lowered) -> Project {
|
||||
ordinary.definitions.extend(lowered.definitions);
|
||||
ordinary.elements.extend(lowered.elements);
|
||||
ordinary
|
||||
}
|
||||
fn admit(project: &Project) {
|
||||
a::admit(project, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
}
|
||||
fn failure(source: &str, ordinary: &Project) -> Failure {
|
||||
let mut budget = budget();
|
||||
let unit = unit(source, 7, &mut budget);
|
||||
lower(ordinary, &[unit], &budget).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() {
|
||||
for (layer, ordinary_source, typed_source) in
|
||||
[(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)]
|
||||
{
|
||||
let expected = oracle::project(layer);
|
||||
assert_eq!(expected.name, "Response budget");
|
||||
assert_eq!(expected.identities().len(), 15); // two additional scoped IDs
|
||||
for (alias, name) in oracle::NAMES {
|
||||
let object = by_alias(&expected, alias);
|
||||
let actual_name = expected
|
||||
.definitions
|
||||
.get(&object)
|
||||
.map(|value| value.name.as_str())
|
||||
.or_else(|| {
|
||||
expected
|
||||
.elements
|
||||
.get(&object)
|
||||
.map(|value| value.name.as_str())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(actual_name, name);
|
||||
assert_ne!(actual_name, alias);
|
||||
}
|
||||
let mut budget = budget();
|
||||
let units = [unit(typed_source, 17, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let numeric = budget.numeric_units();
|
||||
let skeleton = ordinary(ordinary_source);
|
||||
let first = lower(&skeleton, &units, &budget).unwrap();
|
||||
let second = lower(&skeleton, &units, &budget).unwrap();
|
||||
assert_eq!(first.occurrences, second.occurrences);
|
||||
assert_eq!(first.expressions, second.expressions);
|
||||
assert_eq!(budget.usage, usage);
|
||||
assert_eq!(budget.numeric_units(), numeric);
|
||||
assert_eq!(
|
||||
first
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|occurrence| occurrence.role != Role::ParameterDeclaration)
|
||||
.count(),
|
||||
usage.references
|
||||
);
|
||||
let actual = merge(skeleton, first);
|
||||
assert_eq!(actual, expected);
|
||||
admit(&actual); // the complete model's one semantic admission
|
||||
}
|
||||
assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() {
|
||||
let source = OA.replace('\n', "\r\n");
|
||||
let split = source.find("calculation combine").unwrap();
|
||||
let sources = [&source[..split], &source[split..]];
|
||||
let mut budget = budget();
|
||||
let mut units = [
|
||||
unit(sources[0], 4, &mut budget),
|
||||
unit(sources[1], 9, &mut budget),
|
||||
];
|
||||
let usage = budget.usage;
|
||||
assert_eq!(usage.bytes, source.len());
|
||||
let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
units.reverse();
|
||||
// This second order uses the same bounded lowerer; output models must not
|
||||
// depend on declaration/file traversal order. It is not an include parser.
|
||||
let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
assert_eq!(
|
||||
merge(ordinary(ORDINARY_OA), reordered),
|
||||
oracle::project(Layer::Oa)
|
||||
);
|
||||
for occurrence in &lowered.occurrences {
|
||||
let source = if occurrence.file == 4 {
|
||||
sources[0]
|
||||
} else {
|
||||
assert_eq!(occurrence.file, 9);
|
||||
sources[1]
|
||||
};
|
||||
let text = &source[occurrence.span.start..occurrence.span.end];
|
||||
assert!(crate::valid_alias(text));
|
||||
assert!(!text.starts_with('@') && !text.starts_with('$'));
|
||||
}
|
||||
assert_eq!(budget.usage, usage);
|
||||
assert_eq!(
|
||||
merge(ordinary(ORDINARY_OA), lowered),
|
||||
oracle::project(Layer::Oa)
|
||||
);
|
||||
}
|
||||
|
||||
fn number(value: i64) -> a::Expr {
|
||||
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value))
|
||||
}
|
||||
fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr {
|
||||
a::Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() {
|
||||
let mut project = oracle::project(Layer::Oa);
|
||||
let duration = by_alias(&project, "duration");
|
||||
let second = by_alias(&project, "second");
|
||||
let property = by_alias(&project, "receive_time");
|
||||
let owner = by_alias(&project, "total_time");
|
||||
let calculation = by_alias(&project, "combine");
|
||||
let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
|
||||
let quantity =
|
||||
a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration)));
|
||||
let numeric = a::AnalysisType::Scalar(a::ScalarType::Number);
|
||||
let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean);
|
||||
let cases = [
|
||||
("true", a::Expr::Boolean(true), boolean.clone()),
|
||||
(
|
||||
"ratio(3, 2)",
|
||||
a::Expr::Number(
|
||||
Exact::from_wire(
|
||||
&crate::analysis_numeric::ExactWire {
|
||||
numerator: "3".into(),
|
||||
denominator: "2".into(),
|
||||
},
|
||||
&mut crate::analysis_numeric::WorkMeter::new(
|
||||
crate::analysis_numeric::WorkLimits::REFERENCE,
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"2 [@second]",
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(2),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
(
|
||||
"@receive_time",
|
||||
a::Expr::Property(property),
|
||||
quantity.clone(),
|
||||
),
|
||||
("$a", a::Expr::Parameter(parameter), quantity.clone()),
|
||||
(
|
||||
"[1, 2]",
|
||||
a::Expr::List(vec![number(1), number(2)]),
|
||||
a::AnalysisType::List(a::ScalarType::Number),
|
||||
),
|
||||
(
|
||||
"abs(-2)",
|
||||
a::Expr::Unary {
|
||||
op: a::UnaryOp::Absolute,
|
||||
operand: Box::new(a::Expr::Unary {
|
||||
op: a::UnaryOp::Negate,
|
||||
operand: Box::new(number(2)),
|
||||
}),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"1 + 2",
|
||||
binary(a::BinaryOp::Add, number(1), number(2)),
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"if(true, 1, 2)",
|
||||
a::Expr::If {
|
||||
condition: Box::new(a::Expr::Boolean(true)),
|
||||
when_true: Box::new(number(1)),
|
||||
when_false: Box::new(number(2)),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"@combine(b: 2 [@second], a: 1 [@second])",
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(calculation),
|
||||
arguments: BTreeMap::from([
|
||||
(
|
||||
parameter,
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
),
|
||||
(
|
||||
b,
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(2),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
(
|
||||
"sum([1, 2], 0)",
|
||||
a::Expr::Sum {
|
||||
values: Box::new(a::Expr::List(vec![number(1), number(2)])),
|
||||
initial: Box::new(number(0)),
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"power(2, -2)",
|
||||
a::Expr::Power {
|
||||
base: Box::new(number(2)),
|
||||
exponent: -2,
|
||||
},
|
||||
numeric.clone(),
|
||||
),
|
||||
(
|
||||
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
|
||||
a::Expr::AsKind {
|
||||
kind: a::DefinitionRef::Local(duration),
|
||||
value: Box::new(binary(
|
||||
a::BinaryOp::Divide,
|
||||
a::Expr::Power {
|
||||
base: Box::new(a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
}),
|
||||
exponent: 2,
|
||||
},
|
||||
a::Expr::Quantity {
|
||||
magnitude: Exact::from_integer(1),
|
||||
unit: a::DefinitionRef::Local(second),
|
||||
},
|
||||
)),
|
||||
},
|
||||
quantity.clone(),
|
||||
),
|
||||
];
|
||||
// Remove the original check's dependent quantity comparison: each case
|
||||
// installs a different declared result type on total_time below.
|
||||
project.elements.remove(&by_alias(&project, "budget"));
|
||||
for (source, expected, result_type) in cases {
|
||||
let mut budget = budget();
|
||||
let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else {
|
||||
panic!("expression")
|
||||
};
|
||||
let mut tables = Tables::default();
|
||||
tables.ordinary(&project, &budget).unwrap();
|
||||
let mut output = Lowered {
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
occurrences: Vec::new(),
|
||||
expressions: Vec::new(),
|
||||
};
|
||||
let is_parameter = source == "$a";
|
||||
let expression_owner = if is_parameter { calculation } else { owner };
|
||||
let actual = Resolver {
|
||||
tables: &tables,
|
||||
output: &mut output,
|
||||
budget: &budget,
|
||||
file: 3,
|
||||
owner: expression_owner,
|
||||
}
|
||||
.expression(
|
||||
&syntax,
|
||||
if is_parameter {
|
||||
Field::Body
|
||||
} else {
|
||||
Field::Base
|
||||
},
|
||||
is_parameter.then_some(calculation),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected, "{source}");
|
||||
assert_eq!(output.expressions[0].span, syntax.span);
|
||||
assert!(output.expressions[0].parent.is_none());
|
||||
let mut candidate = project.clone();
|
||||
if is_parameter {
|
||||
let a::DefinitionBody::Calculation(definition) =
|
||||
&mut candidate.definitions.get_mut(&calculation).unwrap().body
|
||||
else {
|
||||
panic!("calculation")
|
||||
};
|
||||
definition.body = a::Binding::Expression(actual);
|
||||
} else {
|
||||
let SharedBody::EngineeringProperty {
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
..
|
||||
} = candidate
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("property")
|
||||
};
|
||||
*value_type = result_type;
|
||||
*display_unit = None;
|
||||
*base = a::Binding::Expression(actual);
|
||||
}
|
||||
admit(&candidate);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() {
|
||||
let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, // first\n b: @dispatch_time) + @combine(// second\n b: @dispatch_time, a: @receive_time);") + r#"
|
||||
calculation other "Other" id "00000000-0000-0000-0000-000000000020" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number;
|
||||
result Number; body $a;
|
||||
}
|
||||
quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; }
|
||||
calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" {
|
||||
input a id "00000000-0000-0000-0000-000000000022" : Number;
|
||||
result Number; body @other(a: $a) + @other(a: 3);
|
||||
}
|
||||
"#;
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 2, &mut budget)];
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
|
||||
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
|
||||
let labels: Vec<_> = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|value| value.owner == total && value.role == Role::ArgumentLabel)
|
||||
.collect();
|
||||
assert_eq!(labels.len(), 4);
|
||||
assert_eq!(
|
||||
labels
|
||||
.iter()
|
||||
.map(|value| value.span.start)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
let sites: BTreeSet<_> = labels
|
||||
.iter()
|
||||
.map(|value| value.expression.unwrap())
|
||||
.collect();
|
||||
assert_eq!(sites.len(), 2);
|
||||
let paths: Vec<_> = sites
|
||||
.iter()
|
||||
.map(|site| output.expressions[*site].parent.unwrap().1)
|
||||
.collect();
|
||||
assert_eq!(paths, [Child::Left, Child::Right]);
|
||||
for label in labels {
|
||||
assert_eq!(label.field, Field::Base);
|
||||
assert!(
|
||||
matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine)
|
||||
);
|
||||
assert!(matches!(
|
||||
&source[label.span.start..label.span.end],
|
||||
"a" | "b"
|
||||
));
|
||||
}
|
||||
let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
|
||||
let scopes: BTreeSet<_> = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter_map(|occurrence| match occurrence.target {
|
||||
Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
} if parameter == reused => Some(calculation),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
scopes,
|
||||
BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")])
|
||||
);
|
||||
let caller = id("00000000-0000-0000-0000-000000000021");
|
||||
let use_in_caller = output
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|value| value.owner == caller && value.role == Role::ParameterUse)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
use_in_caller.target,
|
||||
Target::Parameter {
|
||||
calculation: caller,
|
||||
parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022"))
|
||||
}
|
||||
);
|
||||
for site in &output.expressions {
|
||||
assert_eq!(site.file, 2);
|
||||
assert!(source.get(site.span.start..site.span.end).is_some());
|
||||
}
|
||||
admit(&merge(ordinary(ORDINARY_OA), output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_ids_require_stabilization_without_uuid_or_budget_churn() {
|
||||
for source in [
|
||||
r#"calculation pending "Pending" { result Number; body unbound; }"#,
|
||||
r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#,
|
||||
] {
|
||||
let mut budget = budget();
|
||||
let units = [unit(source, 3, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let skeleton = ordinary(ORDINARY_OA);
|
||||
let first = lower(&skeleton, &units, &budget).unwrap_err();
|
||||
assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err());
|
||||
assert!(first.error.message.contains("stabilization"));
|
||||
assert_eq!(budget.usage, usage);
|
||||
let declaration = &units[0].declarations[0];
|
||||
if declaration.header.identity.value.is_none() {
|
||||
assert!(declaration.header.identity.insertion.is_some());
|
||||
} else {
|
||||
let d::Body::Calculation { parameters, .. } = &declaration.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
assert!(parameters[0].identity.value.is_none());
|
||||
assert!(parameters[0].identity.insertion.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_global_aliases_and_ids_fail_before_body_resolution() {
|
||||
let ordinary = ordinary(ORDINARY_OA);
|
||||
for (source, message) in [
|
||||
(
|
||||
OA.replace("quantity_kind duration", "quantity_kind respond"),
|
||||
"Duplicate global alias",
|
||||
),
|
||||
(
|
||||
OA.replace(
|
||||
"188b911c-5f8b-431c-9eea-2d41f3953767",
|
||||
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
|
||||
),
|
||||
"Duplicate global identity",
|
||||
),
|
||||
(format!("{OA}\n{OA}"), "Duplicate global"),
|
||||
] {
|
||||
let failed = failure(&source, &ordinary);
|
||||
assert_eq!(failed.file, Some(7));
|
||||
assert!(failed.error.message.contains(message), "{:?}", failed);
|
||||
assert!(!failed.error.message.contains("Unknown"));
|
||||
}
|
||||
let mut budget = budget();
|
||||
let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)];
|
||||
let failed = lower(&ordinary, &units, &budget).unwrap_err();
|
||||
assert_eq!(failed.file, Some(5));
|
||||
assert!(failed.error.message.contains("Duplicate global identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() {
|
||||
let skeleton = ordinary(ORDINARY_OA);
|
||||
for (old, new, token, message) in [
|
||||
(
|
||||
"subject @receive",
|
||||
"subject @second",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"Quantity(@duration)",
|
||||
"Quantity(@second)",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"display_unit @second",
|
||||
"display_unit @duration",
|
||||
"duration",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"750 [@millisecond]",
|
||||
"750 [@duration]",
|
||||
"duration",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"base 1 [@second]",
|
||||
"base @respond",
|
||||
"respond",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@combine(a:",
|
||||
"@second(a:",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@combine(a:",
|
||||
"@combine(unknown:",
|
||||
"unknown",
|
||||
"Unknown argument",
|
||||
),
|
||||
(
|
||||
"body $a + $b",
|
||||
"body $outside + $b",
|
||||
"outside",
|
||||
"Unknown parameter",
|
||||
),
|
||||
(
|
||||
"base 1 [@second]",
|
||||
"base $a",
|
||||
"a",
|
||||
"only valid in its calculation",
|
||||
),
|
||||
(
|
||||
"subject @receive",
|
||||
"subject @missing",
|
||||
"missing",
|
||||
"Unknown global",
|
||||
),
|
||||
(
|
||||
"documented_constraint none",
|
||||
"documented_constraint @respond",
|
||||
"respond",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
(
|
||||
"@receive_time = unbound",
|
||||
"@second = unbound",
|
||||
"second",
|
||||
"Wrong reference domain",
|
||||
),
|
||||
] {
|
||||
let source = OA.replace(old, new);
|
||||
assert_ne!(source, OA, "mutation {old}");
|
||||
let failed = failure(&source, &skeleton);
|
||||
assert!(
|
||||
failed.error.message.contains(message),
|
||||
"{new}: {:?}",
|
||||
failed
|
||||
);
|
||||
assert_eq!(
|
||||
&source[failed.error.span.start..failed.error.span.end],
|
||||
token
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() {
|
||||
let mut skeleton = ordinary(ORDINARY_OA);
|
||||
let respond = by_alias(&skeleton, "respond");
|
||||
let receive = by_alias(&skeleton, "receive");
|
||||
let relation = Id::from_u128(100);
|
||||
skeleton.relations.insert(
|
||||
relation,
|
||||
crate::Relation {
|
||||
id: relation,
|
||||
alias: "link".into(),
|
||||
name: "Link".into(),
|
||||
description: String::new(),
|
||||
body: crate::RelationBody::new(
|
||||
crate::RelationKind::FunctionalExchange,
|
||||
respond,
|
||||
receive,
|
||||
),
|
||||
},
|
||||
);
|
||||
let diagram = skeleton.ensure_diagram(Layer::Oa);
|
||||
skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into();
|
||||
let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. });
|
||||
skeleton
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = "placement".into();
|
||||
let artifact = Id::from_u128(101);
|
||||
skeleton.artifact_links.insert(
|
||||
artifact,
|
||||
crate::ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "document".into(),
|
||||
name: "Document".into(),
|
||||
description: String::new(),
|
||||
owner: respond,
|
||||
relation: crate::ArtifactRelation::References,
|
||||
uri: "urn:test".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let trace = Id::from_u128(102);
|
||||
skeleton.traces.insert(
|
||||
trace,
|
||||
crate::Trace {
|
||||
id: trace,
|
||||
alias: "mapping".into(),
|
||||
name: "Mapping".into(),
|
||||
description: String::new(),
|
||||
oa: respond,
|
||||
sa: receive,
|
||||
kind: crate::TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
// Resolution classification is independent of whole-model admission; the
|
||||
// synthetic trace is deliberately not asserted to be a valid OA/SA trace.
|
||||
let budget = budget();
|
||||
let mut tables = Tables::default();
|
||||
tables.ordinary(&skeleton, &budget).unwrap();
|
||||
for (alias, category) in [
|
||||
("response_budget", Category::Project),
|
||||
("link", Category::Relation),
|
||||
("view", Category::Diagram),
|
||||
("placement", Category::Node),
|
||||
("document", Category::Artifact),
|
||||
("mapping", Category::Trace),
|
||||
] {
|
||||
assert_eq!(tables.aliases[alias].category, category);
|
||||
let failed = failure(
|
||||
&OA.replace("base 1 [@second]", &format!("base @{alias}")),
|
||||
&skeleton,
|
||||
);
|
||||
assert!(
|
||||
failed.error.message.contains("Wrong reference domain"),
|
||||
"{:?}",
|
||||
failed
|
||||
);
|
||||
}
|
||||
let source = OA.replace("subject @receive", "subject @link");
|
||||
let mut budget = self::budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let output = lower(&skeleton, &units, &budget).unwrap();
|
||||
let property = output
|
||||
.elements
|
||||
.values()
|
||||
.find(|value| value.alias == "receive_time")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() {
|
||||
for (old, new) in [
|
||||
("base 750 [@millisecond]", "base true"),
|
||||
(
|
||||
"base 750 [@millisecond]",
|
||||
"base as_kind(@duration, 1 [@second])",
|
||||
),
|
||||
("body $a + $b", "body @receive_time"),
|
||||
("a: @receive_time, b: @dispatch_time", "a: @receive_time"),
|
||||
("oa_property receive_time", "sa_property receive_time"),
|
||||
("base 1 [@second]", "base @dispatch_time"),
|
||||
] {
|
||||
let source = OA.replace(old, new);
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
let project = merge(ordinary(ORDINARY_OA), output);
|
||||
assert!(
|
||||
a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(),
|
||||
"{new}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() {
|
||||
let ordinary = oracle::project(Layer::Oa);
|
||||
for (maximum, passes) in [(0, false), (16, false), (17, true)] {
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = maximum;
|
||||
let budget = Budget::new(limits).unwrap();
|
||||
let result = lower(&ordinary, &[], &budget);
|
||||
assert_eq!(result.is_ok(), passes, "entry bound {maximum}");
|
||||
if let Err(failure) = result {
|
||||
assert!(failure.error.message.contains("lowering entry limit"));
|
||||
assert_eq!(failure.file, None);
|
||||
}
|
||||
assert_eq!(budget.usage, budget::Usage::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() {
|
||||
let source = r#"
|
||||
unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" {
|
||||
kind @point; scale 1; offset ratio(27315, 100); symbol "°C";
|
||||
}
|
||||
quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" {
|
||||
dimension { temperature 1; }; algebra Affine(@interval);
|
||||
}
|
||||
quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" {
|
||||
dimension { temperature 1; }; algebra Linear;
|
||||
}
|
||||
calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" {
|
||||
input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval));
|
||||
result List(Quantity(@interval)); body unbound; description "保留";
|
||||
}
|
||||
calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" {
|
||||
result Boolean; body unbound;
|
||||
}
|
||||
oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" {
|
||||
subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none;
|
||||
}
|
||||
oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" {
|
||||
subject @respond; documented_constraint @requirement; predicate unbound;
|
||||
}
|
||||
oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" {
|
||||
subject @respond; base ModelBaseline; overrides { @ambient = unbound; };
|
||||
}
|
||||
"#;
|
||||
let mut skeleton = ordinary(ORDINARY_OA);
|
||||
let requirement = Id::from_u128(70);
|
||||
skeleton.elements.insert(
|
||||
requirement,
|
||||
Element {
|
||||
id: requirement,
|
||||
alias: "requirement".into(),
|
||||
name: "Requirement".into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Constraint {
|
||||
expression: String::new(),
|
||||
constrained: Vec::new(),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
let mut budget = budget();
|
||||
let units = [unit(source, 8, &mut budget)];
|
||||
let output = lower(&skeleton, &units, &budget).unwrap();
|
||||
assert_eq!(output.definitions.len(), 5);
|
||||
let point = id("00000000-0000-0000-0000-000000000062");
|
||||
let interval = id("00000000-0000-0000-0000-000000000063");
|
||||
let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else {
|
||||
panic!("quantity kind")
|
||||
};
|
||||
assert_eq!(
|
||||
kind.algebra,
|
||||
a::QuantityAlgebra::Affine {
|
||||
difference: a::DefinitionRef::Local(interval)
|
||||
}
|
||||
);
|
||||
let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")];
|
||||
assert_eq!(unused.description, "保留");
|
||||
let a::DefinitionBody::Calculation(calculation) = &unused.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
assert_eq!(calculation.body, a::Binding::Unbound);
|
||||
assert_eq!(
|
||||
calculation.parameters[0].value_type,
|
||||
a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval)))
|
||||
);
|
||||
assert_eq!(calculation.result, calculation.parameters[0].value_type);
|
||||
assert!(
|
||||
output
|
||||
.occurrences
|
||||
.iter()
|
||||
.any(|occurrence| occurrence.field == Field::DocumentedConstraint
|
||||
&& occurrence.target == Target::Global(requirement))
|
||||
);
|
||||
let override_id = id("00000000-0000-0000-0000-000000000067");
|
||||
assert!(output.occurrences.iter().any(|occurrence| occurrence.field
|
||||
== Field::Override(override_id)
|
||||
&& occurrence.role == Role::OverrideTarget));
|
||||
assert!(
|
||||
output
|
||||
.occurrences
|
||||
.iter()
|
||||
.any(|occurrence| occurrence.owner == point
|
||||
&& occurrence.field == Field::Algebra
|
||||
&& occurrence.target == Target::Global(interval))
|
||||
);
|
||||
admit(&merge(skeleton, output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() {
|
||||
let mut budget = budget();
|
||||
let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)];
|
||||
let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err();
|
||||
assert_eq!(failed.file, Some(3));
|
||||
assert!(failed.error.message.contains("Duplicate source file"));
|
||||
let failure = budget
|
||||
.bytes(usize::MAX, Span { start: 5, end: 6 })
|
||||
.unwrap_err();
|
||||
let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err();
|
||||
assert_eq!(failed.error, failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() {
|
||||
let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127));
|
||||
let wide = std::iter::repeat_n("1", 1024)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let source = format!(
|
||||
r#"
|
||||
calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }}
|
||||
calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }}
|
||||
"#
|
||||
);
|
||||
let mut budget = budget();
|
||||
let units = [unit(&source, 1, &mut budget)];
|
||||
let usage = budget.usage;
|
||||
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
|
||||
assert_eq!(output.expressions.len(), 128 + 1025);
|
||||
let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body
|
||||
else {
|
||||
panic!("deep calculation")
|
||||
};
|
||||
let a::Binding::Expression(root) = &deep.body else {
|
||||
panic!("deep expression")
|
||||
};
|
||||
let mut current = root;
|
||||
let mut depth = 1;
|
||||
while let a::Expr::Unary { operand, .. } = current {
|
||||
depth += 1;
|
||||
current = operand;
|
||||
}
|
||||
assert_eq!(depth, 128);
|
||||
assert_eq!(current, &number(1));
|
||||
let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body
|
||||
else {
|
||||
panic!("wide calculation")
|
||||
};
|
||||
let a::Binding::Expression(a::Expr::List(values)) = &wide.body else {
|
||||
panic!("wide list")
|
||||
};
|
||||
assert_eq!(values.len(), 1024);
|
||||
assert!(values.iter().all(|value| *value == number(1)));
|
||||
assert_eq!(budget.usage, usage);
|
||||
admit(&merge(ordinary(ORDINARY_OA), output));
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
//! 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,
|
||||
// Includes the conservative dropped value-end subtotal. The semantic
|
||||
// phase adds syntax entries to this once, not tokens/positions again.
|
||||
metadata: usize,
|
||||
}
|
||||
|
||||
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,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
pub(super) mod project;
|
||||
@@ -1,639 +0,0 @@
|
||||
//! Private complete-Project admission from the actual complete syntax witness.
|
||||
//! No Index, update authority, public source8 dispatch or replacement policy.
|
||||
use super::super::{
|
||||
Binding, Budget, Error as TypedError, Expression, ExpressionKind, Reference, Span,
|
||||
declarations as d, error, lower,
|
||||
};
|
||||
use super::{FileSyntax, ParsedMixedSyntax};
|
||||
use crate::language::{Ast, DeclKind};
|
||||
use crate::source_project::{SourceDiagnostic, SourceLocation};
|
||||
use crate::{Id, Kind, Project, RelationKind, analysis, language, validation};
|
||||
use std::collections::BTreeMap;
|
||||
mod accounting;
|
||||
use accounting::{Ledger, Slots, add};
|
||||
|
||||
/// Read-only authority: every component originates in the consumed syntax.
|
||||
pub(in crate::language::analysis_source) struct ResolvedMixedProject<'s> {
|
||||
syntax: ParsedMixedSyntax<'s>,
|
||||
project: Project,
|
||||
warnings: Vec<SourceDiagnostic>,
|
||||
static_limits: analysis::AdmissionLimits,
|
||||
static_usage: analysis::AdmissionUsage,
|
||||
expressions: Vec<lower::ExpressionSite>,
|
||||
occurrences: Vec<lower::Occurrence>,
|
||||
storage: Ledger,
|
||||
}
|
||||
impl ResolvedMixedProject<'_> {
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.project
|
||||
}
|
||||
pub fn warnings(&self) -> &[SourceDiagnostic] {
|
||||
&self.warnings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(in crate::language::analysis_source) struct Failure {
|
||||
pub diagnostics: Vec<SourceDiagnostic>,
|
||||
}
|
||||
impl Failure {
|
||||
// Rejection transport is not retained in a successful witness. In particular
|
||||
// a storage refusal needs a small diagnostic even when its allowance is zero.
|
||||
fn typed(syntax: &ParsedMixedSyntax<'_>, file: Option<usize>, e: TypedError) -> Self {
|
||||
let mut failure = Self::ordinary(
|
||||
syntax,
|
||||
file,
|
||||
language::Error {
|
||||
code: "invalid_source",
|
||||
offset: e.span.start,
|
||||
message: e.message,
|
||||
},
|
||||
);
|
||||
failure.diagnostics[0].diagnostic.span = e.span;
|
||||
failure
|
||||
}
|
||||
fn ordinary(syntax: &ParsedMixedSyntax<'_>, file: Option<usize>, e: language::Error) -> Self {
|
||||
let file = file.unwrap_or_else(|| entry(syntax));
|
||||
let unit = &syntax.files[file];
|
||||
Self {
|
||||
diagnostics: super::located(unit.path, unit.source, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OwnerKind {
|
||||
Project,
|
||||
Ordinary(usize),
|
||||
Typed(usize),
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Owner {
|
||||
file: usize,
|
||||
kind: OwnerKind,
|
||||
span: Span,
|
||||
}
|
||||
struct Catalog {
|
||||
tables: lower::Tables,
|
||||
aliases: BTreeMap<String, Id>,
|
||||
owners: BTreeMap<Id, Owner>,
|
||||
tables_storage: Slots,
|
||||
aliases_storage: Slots,
|
||||
owners_storage: Slots,
|
||||
}
|
||||
fn entry(syntax: &ParsedMixedSyntax<'_>) -> usize {
|
||||
syntax
|
||||
.files
|
||||
.iter()
|
||||
.position(|f| f.path == syntax.bundle.entry)
|
||||
.expect("complete entry")
|
||||
}
|
||||
fn category(kind: DeclKind) -> lower::Category {
|
||||
match kind {
|
||||
DeclKind::Element(kind, layer) => lower::Category::Element(kind, layer),
|
||||
DeclKind::Relation(_) => lower::Category::Relation,
|
||||
DeclKind::Diagram => lower::Category::Diagram,
|
||||
DeclKind::Node => lower::Category::Node,
|
||||
DeclKind::Artifact => lower::Category::Artifact,
|
||||
DeclKind::Trace => lower::Category::Trace,
|
||||
}
|
||||
}
|
||||
impl Catalog {
|
||||
fn insert(
|
||||
&mut self,
|
||||
syntax: &ParsedMixedSyntax<'_>,
|
||||
ledger: &mut Ledger,
|
||||
id: Id,
|
||||
alias: &str,
|
||||
owner: Owner,
|
||||
) -> Result<(), Failure> {
|
||||
// Alias first, even when both aliases and IDs collide. Owner is an
|
||||
// occurrence position, never a lookup through the rejected UUID.
|
||||
let prior = self
|
||||
.aliases
|
||||
.get(alias)
|
||||
.map(|id| ("alias", self.owners[id]))
|
||||
.or_else(|| self.owners.get(&id).map(|o| ("identity", *o)));
|
||||
if let Some((domain, prior)) = prior {
|
||||
let mut failure = Failure::typed(
|
||||
syntax,
|
||||
Some(owner.file),
|
||||
error(owner.span, format!("Duplicate global {domain}.")),
|
||||
);
|
||||
failure.diagnostics[0].related.push(SourceLocation {
|
||||
path: syntax.files[prior.file].path.into(),
|
||||
span: prior.span,
|
||||
});
|
||||
return Err(failure);
|
||||
}
|
||||
let alias_slots = Slots {
|
||||
entries: 1,
|
||||
text: alias.len(),
|
||||
..Slots::default()
|
||||
};
|
||||
ledger
|
||||
.reserve(
|
||||
&syntax.budget,
|
||||
alias_slots
|
||||
.add(Slots::entries(1), owner.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?,
|
||||
owner.span,
|
||||
)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||
self.aliases_storage = self
|
||||
.aliases_storage
|
||||
.add(alias_slots, owner.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||
self.owners_storage = self
|
||||
.owners_storage
|
||||
.add(Slots::entries(1), owner.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||
self.aliases.insert(alias.into(), id);
|
||||
self.owners.insert(id, owner);
|
||||
Ok(())
|
||||
}
|
||||
fn reserve_tables(
|
||||
&mut self,
|
||||
syntax: &ParsedMixedSyntax<'_>,
|
||||
ledger: &mut Ledger,
|
||||
slots: Slots,
|
||||
owner: Owner,
|
||||
) -> Result<(), Failure> {
|
||||
ledger
|
||||
.reserve(&syntax.budget, slots, owner.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||
self.tables_storage = self
|
||||
.tables_storage
|
||||
.add(slots, owner.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn catalog(syntax: &ParsedMixedSyntax<'_>, ledger: &mut Ledger) -> Result<Catalog, Failure> {
|
||||
let mut catalog = Catalog {
|
||||
tables: lower::Tables::default(),
|
||||
aliases: BTreeMap::new(),
|
||||
owners: BTreeMap::new(),
|
||||
tables_storage: Slots::default(),
|
||||
aliases_storage: Slots::default(),
|
||||
owners_storage: Slots::default(),
|
||||
};
|
||||
let file = entry(syntax);
|
||||
let header = syntax.files[file]
|
||||
.ordinary
|
||||
.header
|
||||
.as_ref()
|
||||
.expect("entry header");
|
||||
let owner = Owner {
|
||||
file,
|
||||
kind: OwnerKind::Project,
|
||||
span: header.span,
|
||||
};
|
||||
catalog.insert(
|
||||
syntax,
|
||||
ledger,
|
||||
header.project.id,
|
||||
&header.project.alias,
|
||||
owner,
|
||||
)?;
|
||||
catalog.reserve_tables(
|
||||
syntax,
|
||||
ledger,
|
||||
Slots {
|
||||
entries: 2,
|
||||
text: header.project.alias.len(),
|
||||
..Slots::default()
|
||||
},
|
||||
owner,
|
||||
)?;
|
||||
catalog
|
||||
.tables
|
||||
.insert(
|
||||
header.project.id,
|
||||
&header.project.alias,
|
||||
lower::Category::Project,
|
||||
header.span,
|
||||
&syntax.budget,
|
||||
)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
for (file, unit) in syntax.files.iter().enumerate() {
|
||||
let mut ordinary = unit.ordinary.declarations.iter().enumerate().peekable();
|
||||
let mut typed = unit.typed.iter().enumerate().peekable();
|
||||
while ordinary.peek().is_some() || typed.peek().is_some() {
|
||||
let use_ordinary = match (ordinary.peek(), typed.peek()) {
|
||||
(Some((_, a)), Some((_, b))) => a.span.start < b.span.start,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if use_ordinary {
|
||||
let (index, d) = ordinary.next().expect("ordinary head");
|
||||
let owner = Owner {
|
||||
file,
|
||||
kind: OwnerKind::Ordinary(index),
|
||||
span: d.span,
|
||||
};
|
||||
catalog.insert(syntax, ledger, d.id, &d.alias, owner)?;
|
||||
catalog.reserve_tables(
|
||||
syntax,
|
||||
ledger,
|
||||
Slots {
|
||||
entries: 2,
|
||||
text: d.alias.len(),
|
||||
..Slots::default()
|
||||
},
|
||||
owner,
|
||||
)?;
|
||||
catalog
|
||||
.tables
|
||||
.insert(d.id, &d.alias, category(d.kind), d.span, &syntax.budget)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
} else {
|
||||
let (index, d) = typed.next().expect("typed head");
|
||||
let owner = Owner {
|
||||
file,
|
||||
kind: OwnerKind::Typed(index),
|
||||
span: d.span,
|
||||
};
|
||||
catalog.insert(
|
||||
syntax,
|
||||
ledger,
|
||||
d.header.identity.value.expect("syntax explicit ID"),
|
||||
&d.header.alias.name,
|
||||
owner,
|
||||
)?;
|
||||
let mut slots = Slots {
|
||||
entries: 2,
|
||||
text: d.header.alias.name.len(),
|
||||
..Slots::default()
|
||||
};
|
||||
let mut scratch = 0;
|
||||
if let d::Body::Calculation { parameters, .. } = &d.body {
|
||||
slots.entries = add(
|
||||
slots.entries,
|
||||
add(1, parameters.len(), d.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?,
|
||||
d.span,
|
||||
)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
scratch = parameters.len(); // per-signature duplicate-ID set
|
||||
for p in parameters {
|
||||
slots.text = add(slots.text, p.name.name.len(), p.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
}
|
||||
}
|
||||
catalog.reserve_tables(syntax, ledger, slots, owner)?;
|
||||
ledger
|
||||
.reserve(&syntax.budget, Slots::entries(scratch), d.span)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
catalog
|
||||
.tables
|
||||
.typed(d, &syntax.budget)
|
||||
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
ledger.release(Slots::entries(scratch));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn object<'a>(value: Option<&'a Ast>, key: &str) -> Option<&'a Ast> {
|
||||
match value {
|
||||
Some(Ast::Object(fields, _)) => fields.get(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Selector only, not a decoder. This executes before ordinary fields clone.
|
||||
fn active_references(
|
||||
declaration: &language::Declaration,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), language::Error> {
|
||||
let field = |key: &str| declaration.fields.get(key);
|
||||
let arrays = match declaration.kind {
|
||||
DeclKind::Element(Kind::Process | Kind::FunctionalChain | Kind::Scenario, _) => {
|
||||
[field("steps"), None]
|
||||
}
|
||||
DeclKind::Element(Kind::Occurrence, _) => {
|
||||
[object(object(field("body"), "Branch"), "occurrences"), None]
|
||||
}
|
||||
DeclKind::Relation(RelationKind::ContextualLink) => [
|
||||
object(field("source"), "references"),
|
||||
object(field("target"), "references"),
|
||||
],
|
||||
_ => [None, None],
|
||||
};
|
||||
for array in arrays.into_iter().flatten() {
|
||||
if let Ast::Array(items, _) = array {
|
||||
for item in items {
|
||||
let start = item.offset();
|
||||
budget
|
||||
.reference(Span { start, end: start })
|
||||
.map_err(super::ordinary_error)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::language::analysis_source) fn resolve(
|
||||
mut syntax: ParsedMixedSyntax<'_>,
|
||||
) -> Result<ResolvedMixedProject<'_>, Failure> {
|
||||
let mut storage = Ledger::default();
|
||||
let lower_storage = accounting::syntax(&syntax, &mut storage)?;
|
||||
let catalog = catalog(&syntax, &mut storage)?;
|
||||
let entry = entry(&syntax);
|
||||
// Only the empty model header is copied: retain its original syntax/header
|
||||
// ownership and construct the final model through the shared decoder once.
|
||||
let project = syntax.files[entry]
|
||||
.ordinary
|
||||
.header
|
||||
.as_ref()
|
||||
.expect("entry header")
|
||||
.project
|
||||
.clone();
|
||||
let count = syntax
|
||||
.files
|
||||
.iter()
|
||||
.try_fold(0usize, |n, f| {
|
||||
add(n, f.ordinary.declarations.len(), Span::default())
|
||||
})
|
||||
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||
storage
|
||||
.reserve(&syntax.budget, Slots::entries(count), Span::default())
|
||||
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||
let declarations: Vec<_> = syntax
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(file, unit)| unit.ordinary.declarations.iter().map(move |d| (file, d)))
|
||||
.collect();
|
||||
let mut nodes = 0;
|
||||
let constructed = language::construct_declarations(
|
||||
project,
|
||||
declarations.iter().map(|(_, d)| *d),
|
||||
&catalog.aliases,
|
||||
|d| {
|
||||
active_references(d, &mut syntax.budget)?;
|
||||
if matches!(d.kind, DeclKind::Node) {
|
||||
storage
|
||||
.reserve(&syntax.budget, Slots::entries(1), d.span)
|
||||
.map_err(super::ordinary_error)?;
|
||||
nodes += 1;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|(index, error)| {
|
||||
Failure::ordinary(
|
||||
&syntax,
|
||||
declarations.get(index).map(|(file, _)| *file),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let language::ConstructedDeclarations {
|
||||
mut project,
|
||||
legacy_elements,
|
||||
} = constructed;
|
||||
debug_assert!(legacy_elements.is_empty()); // mixed syntax only admits source8
|
||||
drop(legacy_elements);
|
||||
drop(declarations);
|
||||
storage.release(Slots::entries(count));
|
||||
storage.release(Slots::entries(nodes));
|
||||
drop(catalog.aliases);
|
||||
storage.release(catalog.aliases_storage);
|
||||
|
||||
let all_lower = lower_storage
|
||||
.all()
|
||||
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||
storage
|
||||
.reserve(&syntax.budget, all_lower, Span::default())
|
||||
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||
let lower::Lowered {
|
||||
definitions,
|
||||
elements,
|
||||
occurrences,
|
||||
expressions,
|
||||
} = lower::lower_bodies(
|
||||
&catalog.tables,
|
||||
syntax
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(file, unit)| unit.typed.iter().map(move |d| (file, d))),
|
||||
&syntax.budget,
|
||||
)
|
||||
.map_err(|e| Failure::typed(&syntax, e.file, e.error))?;
|
||||
// One global arena transfers verbatim; only now are all Resolver temporaries
|
||||
// dead. Output counts must fit the independently counted reservation.
|
||||
storage.release(Slots {
|
||||
entries: all_lower
|
||||
.entries
|
||||
.checked_sub(expressions.len())
|
||||
.expect("expression bound"),
|
||||
occurrences: all_lower
|
||||
.occurrences
|
||||
.checked_sub(occurrences.len())
|
||||
.expect("occurrence bound"),
|
||||
text: 0,
|
||||
});
|
||||
drop(catalog.tables);
|
||||
storage.release(catalog.tables_storage);
|
||||
for (id, value) in definitions {
|
||||
if project.definitions.contains_key(&id) {
|
||||
return Err(Failure::typed(
|
||||
&syntax,
|
||||
None,
|
||||
error(Span::default(), "Non-disjoint definition merge."),
|
||||
));
|
||||
}
|
||||
project.definitions.insert(id, value);
|
||||
}
|
||||
for (id, value) in elements {
|
||||
if project.elements.contains_key(&id) {
|
||||
return Err(Failure::typed(
|
||||
&syntax,
|
||||
None,
|
||||
error(Span::default(), "Non-disjoint element merge."),
|
||||
));
|
||||
}
|
||||
project.elements.insert(id, value);
|
||||
}
|
||||
syntax
|
||||
.limits
|
||||
.model
|
||||
.check_model(&project)
|
||||
.map_err(|message| {
|
||||
Failure::ordinary(
|
||||
&syntax,
|
||||
None,
|
||||
language::Error {
|
||||
code: "model_limit",
|
||||
offset: 0,
|
||||
message,
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let (issues, admission) = validation::validate_with_usage(&project);
|
||||
// Raw architecture Issues are excluded. Each transferred string, copied path
|
||||
// and destination slot is admitted before mapping/retention; never truncate.
|
||||
let mut warnings = Vec::new();
|
||||
let mut rejected = false;
|
||||
for issue in issues {
|
||||
let owner = issue
|
||||
.element
|
||||
.and_then(|id| catalog.owners.get(&id))
|
||||
.copied()
|
||||
.unwrap_or(catalog.owners[&project.id]);
|
||||
let span = field_span(&syntax.files[owner.file], owner, issue.field.as_deref());
|
||||
let unit = &syntax.files[owner.file];
|
||||
let text = [
|
||||
unit.path.len(),
|
||||
issue.code.len(),
|
||||
issue.field.as_ref().map_or(0, String::len),
|
||||
issue.message.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.try_fold(0, |n, k| add(n, k, span))
|
||||
.map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?;
|
||||
storage
|
||||
.reserve(
|
||||
&syntax.budget,
|
||||
Slots {
|
||||
entries: 1,
|
||||
occurrences: 0,
|
||||
text,
|
||||
},
|
||||
span,
|
||||
)
|
||||
.map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?;
|
||||
rejected |= issue.severity == validation::Severity::Error;
|
||||
let prefix = &unit.source[..span.start];
|
||||
let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
|
||||
let column = prefix.rsplit('\n').next().expect("prefix").chars().count() + 1;
|
||||
warnings.push(SourceDiagnostic {
|
||||
path: unit.path.into(),
|
||||
related: Vec::new(),
|
||||
diagnostic: language::Diagnostic {
|
||||
severity: issue.severity,
|
||||
code: issue.code,
|
||||
object_id: issue.element,
|
||||
field: issue.field,
|
||||
message: issue.message,
|
||||
span,
|
||||
line,
|
||||
column,
|
||||
},
|
||||
});
|
||||
}
|
||||
if rejected {
|
||||
return Err(Failure {
|
||||
diagnostics: warnings,
|
||||
});
|
||||
}
|
||||
let (static_limits, static_usage) = admission.expect("successful validation retains admission");
|
||||
drop(catalog.owners);
|
||||
storage.release(catalog.owners_storage);
|
||||
Ok(ResolvedMixedProject {
|
||||
syntax,
|
||||
project,
|
||||
warnings,
|
||||
static_limits,
|
||||
static_usage,
|
||||
expressions,
|
||||
occurrences,
|
||||
storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact known field or explicit whole-owner fallback. Never guess by offsets.
|
||||
fn field_span(file: &FileSyntax<'_>, owner: Owner, field: Option<&str>) -> Span {
|
||||
let Some(field) = field else {
|
||||
return owner.span;
|
||||
};
|
||||
match owner.kind {
|
||||
OwnerKind::Project => file
|
||||
.ordinary
|
||||
.header
|
||||
.as_ref()
|
||||
.and_then(|h| (field == "description").then_some(h.description).flatten())
|
||||
.unwrap_or(owner.span),
|
||||
OwnerKind::Ordinary(index) => file.ordinary.declarations[index]
|
||||
.field_spans
|
||||
.get(field)
|
||||
.copied()
|
||||
.unwrap_or(owner.span),
|
||||
OwnerKind::Typed(index) => typed_field(&file.typed[index], field).unwrap_or(owner.span),
|
||||
}
|
||||
}
|
||||
fn typed_field(d: &d::Declaration, field: &str) -> Option<Span> {
|
||||
if field == "description" {
|
||||
return d.description.as_ref().map(|f| f.value_span);
|
||||
}
|
||||
if field == "alias" {
|
||||
return Some(d.header.alias.span);
|
||||
}
|
||||
if field == "name" {
|
||||
return Some(d.header.name_span);
|
||||
}
|
||||
match &d.body {
|
||||
d::Body::QuantityKind { dimension, algebra } => match field {
|
||||
"dimension" => Some(dimension.value_span),
|
||||
"algebra" => Some(algebra.value_span),
|
||||
_ => None,
|
||||
},
|
||||
d::Body::Unit {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
symbol,
|
||||
} => match field {
|
||||
"kind" => Some(kind.value_span),
|
||||
"scale" => Some(scale.value_span),
|
||||
"offset" => Some(offset.value_span),
|
||||
"symbol" => Some(symbol.value_span),
|
||||
_ => None,
|
||||
},
|
||||
d::Body::Calculation { result, body, .. } => match field {
|
||||
"result" => Some(result.value_span),
|
||||
"body" => Some(body.value_span),
|
||||
// Current semantic admission reports aggregate "parameters", not a
|
||||
// qualified parameter/type path. Do not manufacture precision from
|
||||
// a UUID substring; lowerer failures already carry exact file/span.
|
||||
_ => None,
|
||||
},
|
||||
d::Body::Property {
|
||||
subject,
|
||||
value_type,
|
||||
base,
|
||||
display_unit,
|
||||
} => match field {
|
||||
"subject" => Some(subject.value_span),
|
||||
"type" | "value_type" => Some(value_type.value_span),
|
||||
"base" => Some(base.value_span),
|
||||
"display_unit" => display_unit.as_ref().map(|f| f.value_span),
|
||||
_ => None,
|
||||
},
|
||||
d::Body::Check {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
} => match field {
|
||||
"subject" => subject.as_ref().map(|f| f.value_span),
|
||||
"documented_constraint" => documented_constraint.as_ref().map(|f| f.value_span),
|
||||
"predicate" => Some(predicate.value_span),
|
||||
_ => None,
|
||||
},
|
||||
d::Body::Case {
|
||||
subject,
|
||||
base,
|
||||
overrides,
|
||||
} => match field {
|
||||
"subject" => subject.as_ref().map(|f| f.value_span),
|
||||
"base" => Some(base.value_span),
|
||||
"overrides" => Some(overrides.value_span),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,352 +0,0 @@
|
||||
//! Logical auxiliary storage, separate from model/serde and raw validator heap.
|
||||
use super::*;
|
||||
use crate::language::{EditKind, EditValue};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct Slots {
|
||||
pub entries: usize,
|
||||
pub occurrences: usize,
|
||||
pub text: usize,
|
||||
}
|
||||
impl Slots {
|
||||
pub fn entries(entries: usize) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub fn text(text: usize) -> Self {
|
||||
Self {
|
||||
text,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub fn add(self, other: Self, at: Span) -> Result<Self, super::super::super::Error> {
|
||||
Ok(Self {
|
||||
entries: add(self.entries, other.entries, at)?,
|
||||
occurrences: add(self.occurrences, other.occurrences, at)?,
|
||||
text: add(self.text, other.text, at)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) fn add(a: usize, b: usize, at: Span) -> Result<usize, super::super::super::Error> {
|
||||
a.checked_add(b)
|
||||
.ok_or_else(|| error(at, "Mixed auxiliary storage size overflow."))
|
||||
}
|
||||
pub(super) fn mul(a: usize, b: usize, at: Span) -> Result<usize, super::super::super::Error> {
|
||||
a.checked_mul(b)
|
||||
.ok_or_else(|| error(at, "Mixed auxiliary storage size overflow."))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Ledger {
|
||||
pub live: Slots,
|
||||
}
|
||||
impl Ledger {
|
||||
pub fn reserve(&mut self, budget: &Budget, slots: Slots, at: Span) -> Result<(), TypedError> {
|
||||
let next = self.live.add(slots, at)?;
|
||||
budget.lowering_entries(next.entries, at)?;
|
||||
budget.lowering_occurrences(next.occurrences, at)?;
|
||||
budget.index_bytes(next.text, at)?;
|
||||
self.live = next;
|
||||
Ok(())
|
||||
}
|
||||
pub fn release(&mut self, slots: Slots) {
|
||||
self.live.entries = self
|
||||
.live
|
||||
.entries
|
||||
.checked_sub(slots.entries)
|
||||
.expect("entry reservation");
|
||||
self.live.occurrences = self
|
||||
.live
|
||||
.occurrences
|
||||
.checked_sub(slots.occurrences)
|
||||
.expect("occurrence reservation");
|
||||
self.live.text = self
|
||||
.live
|
||||
.text
|
||||
.checked_sub(slots.text)
|
||||
.expect("text reservation");
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact output-site counts and conservative whole-lowering temporary bound.
|
||||
/// N expression nodes: at most 2N Visit/Build slots, N borrowed child slots,
|
||||
/// N value/split slots. A arguments: A signature slots plus A seen-set slots.
|
||||
/// `Resolver::call` borrows the callee signature. Its parameter Vec/seen set
|
||||
/// contain only supplied arguments, including on incomplete/invalid calls;
|
||||
/// no allocation is proportional to the full signature during body lowering.
|
||||
/// The model expressions/maps themselves are outside this auxiliary ledger.
|
||||
#[derive(Default)]
|
||||
pub(super) struct LowerStorage {
|
||||
pub nodes: usize,
|
||||
pub occurrences: usize,
|
||||
pub arguments: usize,
|
||||
}
|
||||
impl LowerStorage {
|
||||
pub fn all(&self) -> Result<Slots, TypedError> {
|
||||
let at = Span::default();
|
||||
Ok(Slots {
|
||||
entries: add(mul(self.nodes, 5, at)?, mul(self.arguments, 2, at)?, at)?,
|
||||
occurrences: self.occurrences,
|
||||
text: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum Item<'a> {
|
||||
Ast(&'a Ast),
|
||||
Edit(&'a EditValue),
|
||||
Expr(&'a Expression),
|
||||
}
|
||||
struct Walk<'a, 'b> {
|
||||
ledger: &'b mut Ledger,
|
||||
budget: &'b Budget,
|
||||
work: Vec<Item<'a>>,
|
||||
lower: LowerStorage,
|
||||
}
|
||||
impl<'a> Walk<'a, '_> {
|
||||
fn text(&mut self, text: &str, at: Span) -> Result<(), TypedError> {
|
||||
self.ledger
|
||||
.reserve(self.budget, Slots::text(text.len()), at)
|
||||
}
|
||||
fn push(&mut self, item: Item<'a>) -> Result<(), TypedError> {
|
||||
self.ledger
|
||||
.reserve(self.budget, Slots::entries(1), Span::default())?;
|
||||
self.work.push(item);
|
||||
Ok(())
|
||||
}
|
||||
fn reference(&mut self, r: &Reference) -> Result<(), TypedError> {
|
||||
self.text(&r.name, r.span)?;
|
||||
self.lower.occurrences = add(self.lower.occurrences, 1, r.span)?;
|
||||
Ok(())
|
||||
}
|
||||
fn optional(&mut self, field: &Option<d::Field<Option<Reference>>>) -> Result<(), TypedError> {
|
||||
if let Some(r) = field.as_ref().and_then(|f| f.value.as_ref()) {
|
||||
self.reference(r)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn ty(&mut self, ty: &d::TypeSyntax) -> Result<(), TypedError> {
|
||||
if let d::ScalarKind::Quantity(r) = &ty.scalar.kind {
|
||||
self.reference(r)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn binding(&mut self, binding: &'a Binding) -> Result<(), TypedError> {
|
||||
if let Binding::Expression(e) = binding {
|
||||
self.push(Item::Expr(e))?;
|
||||
}
|
||||
self.drain()
|
||||
}
|
||||
fn drain(&mut self) -> Result<(), TypedError> {
|
||||
while let Some(item) = self.work.pop() {
|
||||
self.ledger.release(Slots::entries(1));
|
||||
match item {
|
||||
Item::Ast(ast) => match ast {
|
||||
Ast::Literal(serde_json::Value::String(s), _)
|
||||
| Ast::Word(s, _)
|
||||
| Ast::Reference(s, _) => self.text(s, Span::default())?,
|
||||
Ast::Array(items, _) => {
|
||||
for child in items {
|
||||
self.push(Item::Ast(child))?;
|
||||
}
|
||||
}
|
||||
Ast::Object(fields, _) => {
|
||||
for (key, child) in fields {
|
||||
self.text(key, Span::default())?;
|
||||
self.push(Item::Ast(child))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Item::Edit(value) => match &value.kind {
|
||||
EditKind::Reference(s) => self.text(s, value.span)?,
|
||||
EditKind::Record(fields) => {
|
||||
for (key, field) in fields {
|
||||
self.text(key, field.span)?;
|
||||
self.push(Item::Edit(&field.value))?;
|
||||
}
|
||||
}
|
||||
EditKind::List(items) => {
|
||||
for child in items {
|
||||
self.push(Item::Edit(child))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Item::Expr(e) => {
|
||||
self.lower.nodes = add(self.lower.nodes, 1, e.span)?;
|
||||
use ExpressionKind as E;
|
||||
match &e.kind {
|
||||
E::Quantity { unit, .. } | E::Property(unit) | E::Parameter(unit) => {
|
||||
self.reference(unit)?
|
||||
}
|
||||
E::List(items) => {
|
||||
for child in items {
|
||||
self.push(Item::Expr(child))?;
|
||||
}
|
||||
}
|
||||
E::Unary { operand, .. } => self.push(Item::Expr(operand))?,
|
||||
E::Binary { left, right, .. } => {
|
||||
self.push(Item::Expr(left))?;
|
||||
self.push(Item::Expr(right))?;
|
||||
}
|
||||
E::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
self.push(Item::Expr(condition))?;
|
||||
self.push(Item::Expr(when_true))?;
|
||||
self.push(Item::Expr(when_false))?;
|
||||
}
|
||||
E::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
self.reference(definition)?;
|
||||
self.lower.arguments =
|
||||
add(self.lower.arguments, arguments.len(), e.span)?;
|
||||
for argument in arguments {
|
||||
self.reference(&argument.parameter)?;
|
||||
self.push(Item::Expr(&argument.value))?;
|
||||
}
|
||||
}
|
||||
E::Sum { values, initial } => {
|
||||
self.push(Item::Expr(values))?;
|
||||
self.push(Item::Expr(initial))?;
|
||||
}
|
||||
E::Power { base, .. } => self.push(Item::Expr(base))?,
|
||||
E::AsKind { kind, value } => {
|
||||
self.reference(kind)?;
|
||||
self.push(Item::Expr(value))?;
|
||||
}
|
||||
E::Boolean(_) | E::Number(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn syntax(
|
||||
syntax: &ParsedMixedSyntax<'_>,
|
||||
ledger: &mut Ledger,
|
||||
) -> Result<LowerStorage, Failure> {
|
||||
let at = Span::default();
|
||||
ledger
|
||||
.reserve(
|
||||
&syntax.budget,
|
||||
Slots::entries(
|
||||
add(syntax.metadata, syntax.budget.usage.entries, at)
|
||||
.map_err(|e| Failure::typed(syntax, None, e))?,
|
||||
),
|
||||
at,
|
||||
)
|
||||
.map_err(|e| Failure::typed(syntax, None, e))?;
|
||||
let mut walk = Walk {
|
||||
ledger,
|
||||
budget: &syntax.budget,
|
||||
work: Vec::new(),
|
||||
lower: LowerStorage::default(),
|
||||
};
|
||||
for (file, unit) in syntax.files.iter().enumerate() {
|
||||
let mut count = || -> Result<(), TypedError> {
|
||||
if let Some(header) = &unit.ordinary.header {
|
||||
for s in [
|
||||
&header.project.alias,
|
||||
&header.project.name,
|
||||
&header.project.description,
|
||||
] {
|
||||
walk.text(s, header.span)?;
|
||||
}
|
||||
}
|
||||
for (path, span) in &unit.ordinary.includes {
|
||||
walk.text(path, *span)?;
|
||||
}
|
||||
for declaration in &unit.ordinary.declarations {
|
||||
walk.text(&declaration.alias, declaration.span)?;
|
||||
walk.text(&declaration.name, declaration.span)?;
|
||||
for key in declaration.field_spans.keys() {
|
||||
walk.text(key, declaration.span)?;
|
||||
}
|
||||
for (key, value) in &declaration.fields {
|
||||
walk.text(key, declaration.span)?;
|
||||
walk.push(Item::Ast(value))?;
|
||||
walk.drain()?;
|
||||
}
|
||||
}
|
||||
for (_, position) in &unit.positions {
|
||||
walk.push(Item::Edit(&position.fields))?;
|
||||
walk.drain()?;
|
||||
}
|
||||
for declaration in &unit.typed {
|
||||
walk.text(
|
||||
&declaration.header.alias.name,
|
||||
declaration.header.alias.span,
|
||||
)?;
|
||||
walk.text(&declaration.header.name, declaration.header.name_span)?;
|
||||
if let Some(f) = &declaration.description {
|
||||
walk.text(&f.value, f.span)?;
|
||||
}
|
||||
match &declaration.body {
|
||||
d::Body::QuantityKind { algebra, .. } => {
|
||||
if let d::AlgebraSyntax::Affine(r) = &algebra.value {
|
||||
walk.reference(r)?;
|
||||
}
|
||||
}
|
||||
d::Body::Unit { kind, symbol, .. } => {
|
||||
walk.reference(&kind.value)?;
|
||||
walk.text(&symbol.value, symbol.span)?;
|
||||
}
|
||||
d::Body::Calculation {
|
||||
parameters,
|
||||
result,
|
||||
body,
|
||||
} => {
|
||||
for p in parameters {
|
||||
walk.reference(&p.name)?;
|
||||
walk.ty(&p.value_type)?;
|
||||
}
|
||||
walk.ty(&result.value)?;
|
||||
walk.binding(&body.value)?;
|
||||
}
|
||||
d::Body::Property {
|
||||
subject,
|
||||
value_type,
|
||||
base,
|
||||
display_unit,
|
||||
} => {
|
||||
walk.reference(&subject.value)?;
|
||||
walk.ty(&value_type.value)?;
|
||||
walk.binding(&base.value)?;
|
||||
walk.optional(display_unit)?;
|
||||
}
|
||||
d::Body::Check {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
} => {
|
||||
walk.optional(subject)?;
|
||||
walk.optional(documented_constraint)?;
|
||||
walk.binding(&predicate.value)?;
|
||||
}
|
||||
d::Body::Case {
|
||||
subject, overrides, ..
|
||||
} => {
|
||||
walk.optional(subject)?;
|
||||
for entry in &overrides.value {
|
||||
walk.reference(&entry.target)?;
|
||||
walk.binding(&entry.binding)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
count().map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||
}
|
||||
Ok(walk.lower)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,263 +0,0 @@
|
||||
//! Independent model construction: never parses source or calls the writer.
|
||||
use crate::analysis::*;
|
||||
use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter};
|
||||
use crate::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) const NAMES: [(&str, &str); 14] = [
|
||||
("duration", "Duration"),
|
||||
("second", "Second"),
|
||||
("millisecond", "Millisecond"),
|
||||
("combine", "Combine durations"),
|
||||
("respond", "Respond"),
|
||||
("receive", "Receive"),
|
||||
("dispatch", "Dispatch"),
|
||||
("receive_time", "Receive time"),
|
||||
("dispatch_time", "Dispatch time"),
|
||||
("total_time", "Total time"),
|
||||
("limit", "Allowed duration"),
|
||||
("budget", "Response budget"),
|
||||
("degraded", "Degraded radio"),
|
||||
("unmeasured", "Unmeasured radio"),
|
||||
];
|
||||
|
||||
fn named(alias: &str) -> String {
|
||||
NAMES
|
||||
.iter()
|
||||
.find(|(key, _)| *key == alias)
|
||||
.unwrap()
|
||||
.1
|
||||
.into()
|
||||
}
|
||||
fn local(id: Id) -> DefinitionRef {
|
||||
DefinitionRef::Local(id)
|
||||
}
|
||||
fn quantity(value: i64, unit: Id) -> Expr {
|
||||
Expr::Quantity {
|
||||
magnitude: Exact::from_integer(value),
|
||||
unit: local(unit),
|
||||
}
|
||||
}
|
||||
fn expression(value: Expr) -> Binding {
|
||||
Binding::Expression(value)
|
||||
}
|
||||
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
|
||||
Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn project(layer: Layer) -> Project {
|
||||
// OA is the fixed r2 set; SA independently constructs fresh global IDs.
|
||||
let id = |text| {
|
||||
let id = Id::parse_str(text).unwrap();
|
||||
if layer == Layer::Oa {
|
||||
id
|
||||
} else {
|
||||
Id::from_u128(id.as_u128() ^ (1 << 127))
|
||||
}
|
||||
};
|
||||
let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d");
|
||||
let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767");
|
||||
let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce");
|
||||
let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891");
|
||||
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
|
||||
// Parameter IDs may be reused in another calculation/project.
|
||||
let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap());
|
||||
let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap());
|
||||
let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59");
|
||||
let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353");
|
||||
let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b");
|
||||
let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450");
|
||||
let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace");
|
||||
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
|
||||
let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec");
|
||||
let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf");
|
||||
let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93");
|
||||
let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde");
|
||||
let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration)));
|
||||
let definition = |id, alias: &str, body| Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
body,
|
||||
};
|
||||
let shared = |id, alias: &str, body| Element {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::shared_in(layer, body),
|
||||
};
|
||||
let work = |id, alias: &str, parent| Element {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: named(alias),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: if layer == Layer::Oa {
|
||||
ElementBody::Oa(OaBody::Activity)
|
||||
} else {
|
||||
ElementBody::Sa(SaBody::Function)
|
||||
},
|
||||
};
|
||||
let definitions = [
|
||||
definition(
|
||||
duration,
|
||||
"duration",
|
||||
DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: QuantityAlgebra::Linear,
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
second,
|
||||
"second",
|
||||
DefinitionBody::Unit(UnitDefinition {
|
||||
kind: local(duration),
|
||||
scale: Exact::from_integer(1),
|
||||
offset: Exact::from_integer(0),
|
||||
symbol: "s".into(),
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
millisecond,
|
||||
"millisecond",
|
||||
DefinitionBody::Unit(UnitDefinition {
|
||||
kind: local(duration),
|
||||
scale: Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "1000".into(),
|
||||
},
|
||||
&mut WorkMeter::new(WorkLimits::REFERENCE),
|
||||
)
|
||||
.unwrap(),
|
||||
offset: Exact::from_integer(0),
|
||||
symbol: "ms".into(),
|
||||
}),
|
||||
),
|
||||
definition(
|
||||
combine,
|
||||
"combine",
|
||||
DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters: vec![
|
||||
Parameter {
|
||||
id: a,
|
||||
name: "a".into(),
|
||||
value_type: duration_type(),
|
||||
},
|
||||
Parameter {
|
||||
id: b,
|
||||
name: "b".into(),
|
||||
value_type: duration_type(),
|
||||
},
|
||||
],
|
||||
result: duration_type(),
|
||||
body: expression(binary(
|
||||
BinaryOp::Add,
|
||||
Expr::Parameter(a),
|
||||
Expr::Parameter(b),
|
||||
)),
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|definition| (definition.id, definition))
|
||||
.collect();
|
||||
let property = |id, alias, subject, display, base| {
|
||||
shared(
|
||||
id,
|
||||
alias,
|
||||
SharedBody::EngineeringProperty {
|
||||
subject: AnalysisSubject::Element(subject),
|
||||
value_type: duration_type(),
|
||||
display_unit: Some(local(display)),
|
||||
base: expression(base),
|
||||
},
|
||||
)
|
||||
};
|
||||
let case = |id, alias, binding| {
|
||||
shared(
|
||||
id,
|
||||
alias,
|
||||
SharedBody::AnalysisCase {
|
||||
subject: None,
|
||||
base: BaselineBase::ModelBaseline,
|
||||
overrides: BTreeMap::from([(receive_time, binding)]),
|
||||
},
|
||||
)
|
||||
};
|
||||
let elements = [
|
||||
work(respond, "respond", None),
|
||||
work(receive, "receive", Some(respond)),
|
||||
work(dispatch, "dispatch", Some(respond)),
|
||||
property(
|
||||
receive_time,
|
||||
"receive_time",
|
||||
receive,
|
||||
millisecond,
|
||||
quantity(750, millisecond),
|
||||
),
|
||||
property(
|
||||
dispatch_time,
|
||||
"dispatch_time",
|
||||
dispatch,
|
||||
second,
|
||||
quantity(1, second),
|
||||
),
|
||||
property(
|
||||
total,
|
||||
"total_time",
|
||||
respond,
|
||||
second,
|
||||
Expr::Call {
|
||||
definition: local(combine),
|
||||
arguments: BTreeMap::from([
|
||||
(a, Expr::Property(receive_time)),
|
||||
(b, Expr::Property(dispatch_time)),
|
||||
]),
|
||||
},
|
||||
),
|
||||
property(limit, "limit", respond, second, quantity(2, second)),
|
||||
shared(
|
||||
check,
|
||||
"budget",
|
||||
SharedBody::AnalysisCheck {
|
||||
subject: None,
|
||||
documented_constraint: None,
|
||||
predicate: expression(binary(
|
||||
BinaryOp::LessOrEqual,
|
||||
Expr::Property(total),
|
||||
Expr::Property(limit),
|
||||
)),
|
||||
},
|
||||
),
|
||||
case(
|
||||
degraded,
|
||||
"degraded",
|
||||
expression(quantity(1250, millisecond)),
|
||||
),
|
||||
case(unmeasured, "unmeasured", Binding::Unbound),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
Project {
|
||||
schema_version: 8,
|
||||
id: project_id,
|
||||
alias: "response_budget".into(),
|
||||
name: "Response budget".into(),
|
||||
description: String::new(),
|
||||
definitions,
|
||||
elements,
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
//! Iterative Pratt machine. Source nesting does not recurse on the native stack.
|
||||
use super::cursor::Cursor;
|
||||
use super::*;
|
||||
use lex::Class;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
enum SequenceKind {
|
||||
List,
|
||||
If,
|
||||
Sum,
|
||||
Unary(UnaryOp),
|
||||
Call(Reference),
|
||||
}
|
||||
struct Sequence {
|
||||
kind: SequenceKind,
|
||||
start: usize,
|
||||
values: Vec<(Option<Reference>, Expression)>,
|
||||
keys: BTreeSet<String>,
|
||||
}
|
||||
enum Task {
|
||||
Expression(u8),
|
||||
Continue(u8),
|
||||
Binary(Expression, BinaryOp),
|
||||
Unary(usize, UnaryOp),
|
||||
Group(usize),
|
||||
Next(Sequence),
|
||||
Collected(Sequence, Option<Reference>),
|
||||
Power(usize),
|
||||
AsKind(usize, Reference),
|
||||
}
|
||||
pub(super) fn binding(p: &mut Cursor<'_, '_>) -> Result<Binding, Error> {
|
||||
p.budget.active()?;
|
||||
if p.text() == "unbound" {
|
||||
let span = p.span();
|
||||
p.cursor += 1;
|
||||
return Ok(Binding::Unbound(span));
|
||||
}
|
||||
let mut tasks = vec![Task::Expression(0)];
|
||||
let mut values = Vec::new();
|
||||
while let Some(task) = tasks.pop() {
|
||||
p.budget.active()?;
|
||||
match task {
|
||||
Task::Expression(minimum) => {
|
||||
let start = p.span().start;
|
||||
if p.text() != "(" {
|
||||
p.budget.entry(p.span())?;
|
||||
}
|
||||
tasks.push(Task::Continue(minimum));
|
||||
match p.text() {
|
||||
"true" | "false" => {
|
||||
let value = p.text() == "true";
|
||||
let span = p.span();
|
||||
p.cursor += 1;
|
||||
values.push(p.node(ExpressionKind::Boolean(value), span)?);
|
||||
}
|
||||
"-" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Unary(start, UnaryOp::Negate));
|
||||
tasks.push(Task::Expression(7));
|
||||
}
|
||||
"(" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Group(start));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
"[" => {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Next(Sequence::new(SequenceKind::List, start)));
|
||||
}
|
||||
"@" => {
|
||||
p.cursor += 1;
|
||||
let reference = p.reference()?;
|
||||
if p.text() == "(" {
|
||||
p.open()?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Next(Sequence::new(
|
||||
SequenceKind::Call(reference),
|
||||
start,
|
||||
)));
|
||||
} else {
|
||||
let end = reference.span.end;
|
||||
values.push(
|
||||
p.node(ExpressionKind::Property(reference), Span { start, end })?,
|
||||
);
|
||||
}
|
||||
}
|
||||
"$" => {
|
||||
p.cursor += 1;
|
||||
let reference = p.reference()?;
|
||||
let end = reference.span.end;
|
||||
values.push(
|
||||
p.node(ExpressionKind::Parameter(reference), Span { start, end })?,
|
||||
);
|
||||
}
|
||||
"ratio" => {
|
||||
let (value, literal) = p.exact_literal()?;
|
||||
values.push(p.number(value, literal)?);
|
||||
}
|
||||
"abs" | "not" | "if" | "sum" => {
|
||||
let kind = match p.text() {
|
||||
"abs" => SequenceKind::Unary(UnaryOp::Absolute),
|
||||
"not" => SequenceKind::Unary(UnaryOp::Not),
|
||||
"if" => SequenceKind::If,
|
||||
_ => SequenceKind::Sum,
|
||||
};
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
tasks.push(Task::Next(Sequence::new(kind, start)));
|
||||
}
|
||||
"power" => {
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
tasks.push(Task::Power(start));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
"as_kind" => {
|
||||
p.cursor += 1;
|
||||
p.open()?;
|
||||
p.take("(")?;
|
||||
p.take("@")?;
|
||||
let kind = p.reference()?;
|
||||
p.take(",")?;
|
||||
tasks.push(Task::AsKind(start, kind));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
_ if p
|
||||
.tokens
|
||||
.get(p.cursor)
|
||||
.is_some_and(|token| token.class == Class::Number) =>
|
||||
{
|
||||
let (value, literal) = p.exact_literal()?;
|
||||
values.push(p.number(value, literal)?);
|
||||
}
|
||||
_ => return Err(error(p.span(), "Expected an analysis expression.")),
|
||||
}
|
||||
}
|
||||
Task::Continue(minimum) => {
|
||||
if let Some((op, precedence)) =
|
||||
operator(p.text()).filter(|(_, prec)| *prec >= minimum)
|
||||
{
|
||||
let left = values.pop().expect("completed Pratt operand");
|
||||
if comparison(op) && ungrouped_comparison(&left) {
|
||||
return Err(error(p.span(), "Comparison children require parentheses."));
|
||||
}
|
||||
p.budget.entry(p.span())?;
|
||||
p.cursor += 1;
|
||||
tasks.push(Task::Continue(minimum));
|
||||
tasks.push(Task::Binary(left, op));
|
||||
tasks.push(Task::Expression(precedence + 1));
|
||||
}
|
||||
}
|
||||
Task::Binary(left, op) => {
|
||||
let right = values.pop().expect("completed right operand");
|
||||
if comparison(op) && ungrouped_comparison(&right) {
|
||||
return Err(error(
|
||||
right.span,
|
||||
"Comparison children require parentheses.",
|
||||
));
|
||||
}
|
||||
let span = Span {
|
||||
start: left.span.start,
|
||||
end: right.span.end,
|
||||
};
|
||||
values.push(p.node(
|
||||
ExpressionKind::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
},
|
||||
span,
|
||||
)?);
|
||||
}
|
||||
Task::Unary(start, op) => {
|
||||
let operand = values.pop().expect("completed unary operand");
|
||||
let end = operand.span.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::Unary {
|
||||
op,
|
||||
operand: Box::new(operand),
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
Task::Group(start) => {
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
let value = values.last_mut().expect("completed grouped expression");
|
||||
value.grouped = true;
|
||||
value.span = Span { start, end };
|
||||
}
|
||||
Task::Next(mut sequence) => {
|
||||
let closing = if matches!(sequence.kind, SequenceKind::List) {
|
||||
"]"
|
||||
} else {
|
||||
")"
|
||||
};
|
||||
if p.text() == closing {
|
||||
let end = p.take(closing)?.end;
|
||||
p.close();
|
||||
values.push(p.sequence(sequence, end)?);
|
||||
} else {
|
||||
let count = sequence.values.len();
|
||||
let maximum = match sequence.kind {
|
||||
SequenceKind::List => crate::analysis::MAX_LIST_LENGTH,
|
||||
SequenceKind::Unary(_) => 1,
|
||||
SequenceKind::If => 3,
|
||||
SequenceKind::Sum => 2,
|
||||
SequenceKind::Call(_) => usize::MAX,
|
||||
};
|
||||
if count >= maximum {
|
||||
return Err(error(
|
||||
p.span(),
|
||||
"Too many list members or constructor arguments.",
|
||||
));
|
||||
}
|
||||
let key = if matches!(sequence.kind, SequenceKind::Call(_)) {
|
||||
p.budget.entry(p.span())?;
|
||||
let key = p.reference()?;
|
||||
if !sequence.keys.insert(key.name.clone()) {
|
||||
return Err(error(key.span, "Duplicate call argument name."));
|
||||
}
|
||||
p.take(":")?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tasks.push(Task::Collected(sequence, key));
|
||||
tasks.push(Task::Expression(0));
|
||||
}
|
||||
}
|
||||
Task::Collected(mut sequence, key) => {
|
||||
sequence
|
||||
.values
|
||||
.push((key, values.pop().expect("completed sequence value")));
|
||||
let closing = if matches!(sequence.kind, SequenceKind::List) {
|
||||
"]"
|
||||
} else {
|
||||
")"
|
||||
};
|
||||
if p.text() != closing {
|
||||
p.take(",")?;
|
||||
if p.text() == closing {
|
||||
return Err(error(p.span(), "Trailing comma is not an expression."));
|
||||
}
|
||||
}
|
||||
tasks.push(Task::Next(sequence));
|
||||
}
|
||||
Task::Power(start) => {
|
||||
let base = values.pop().expect("completed power base");
|
||||
p.take(",")?;
|
||||
let negative = if p.text() == "-" {
|
||||
p.cursor += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let span = p.integer()?;
|
||||
let magnitude = p.source[span.start..span.end]
|
||||
.parse::<u8>()
|
||||
.map_err(|_| error(span, "Power exponent is outside -16..16."))?;
|
||||
if magnitude > 16 {
|
||||
return Err(error(span, "Power exponent is outside -16..16."));
|
||||
}
|
||||
let exponent = if negative {
|
||||
-(magnitude as i8)
|
||||
} else {
|
||||
magnitude as i8
|
||||
};
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::Power {
|
||||
base: Box::new(base),
|
||||
exponent,
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
Task::AsKind(start, kind) => {
|
||||
let value = values.pop().expect("completed kind assertion value");
|
||||
let end = p.take(")")?.end;
|
||||
p.close();
|
||||
values.push(p.node(
|
||||
ExpressionKind::AsKind {
|
||||
kind,
|
||||
value: Box::new(value),
|
||||
},
|
||||
Span { start, end },
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Binding::Expression(
|
||||
values.pop().expect("one complete expression"),
|
||||
))
|
||||
}
|
||||
impl Sequence {
|
||||
fn new(kind: SequenceKind, start: usize) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
start,
|
||||
values: Vec::new(),
|
||||
keys: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Cursor<'_, '_> {
|
||||
fn number(&mut self, magnitude: Exact, literal: LiteralSyntax) -> Result<Expression, Error> {
|
||||
let Span { start, end } = literal.span;
|
||||
let mut expression = if self.text() == "[" {
|
||||
self.open()?;
|
||||
self.cursor += 1;
|
||||
self.take("@")?;
|
||||
let unit = self.reference()?;
|
||||
let end = self.take("]")?.end;
|
||||
self.close();
|
||||
self.node(
|
||||
ExpressionKind::Quantity { magnitude, unit },
|
||||
Span { start, end },
|
||||
)
|
||||
} else {
|
||||
self.node(ExpressionKind::Number(magnitude), Span { start, end })
|
||||
}?;
|
||||
expression.literal = Some(literal);
|
||||
Ok(expression)
|
||||
}
|
||||
fn node(&mut self, kind: ExpressionKind, span: Span) -> Result<Expression, Error> {
|
||||
let child_depth = match &kind {
|
||||
ExpressionKind::Unary { operand, .. } => operand.depth,
|
||||
ExpressionKind::Binary { left, right, .. } => left.depth.max(right.depth),
|
||||
ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => condition.depth.max(when_true.depth).max(when_false.depth),
|
||||
ExpressionKind::List(values) => values.iter().map(|v| v.depth).max().unwrap_or(0),
|
||||
ExpressionKind::Call { arguments, .. } => {
|
||||
arguments.iter().map(|a| a.value.depth).max().unwrap_or(0)
|
||||
}
|
||||
ExpressionKind::Sum { values, initial } => values.depth.max(initial.depth),
|
||||
ExpressionKind::Power { base, .. } => base.depth,
|
||||
ExpressionKind::AsKind { value, .. } => value.depth,
|
||||
_ => 0,
|
||||
};
|
||||
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use budget::{Budget, Limits};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn budget() -> Budget {
|
||||
Budget::new(limits()).unwrap()
|
||||
}
|
||||
fn expression(source: &str) -> Expression {
|
||||
match parse(source, &mut budget()).unwrap() {
|
||||
Binding::Expression(value) => value,
|
||||
Binding::Unbound(_) => panic!("expected expression"),
|
||||
}
|
||||
}
|
||||
fn number(value: &Expression) -> String {
|
||||
let ExpressionKind::Number(exact) = &value.kind else {
|
||||
panic!("expected Number");
|
||||
};
|
||||
exact.canonical_literal()
|
||||
}
|
||||
|
||||
fn binary_children(value: Expression, expected: BinaryOp) -> (Expression, Expression) {
|
||||
let ExpressionKind::Binary { op, left, right } = value.kind else {
|
||||
panic!("expected Binary {expected:?}");
|
||||
};
|
||||
assert_eq!(op, expected);
|
||||
(*left, *right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_precedence_and_grouping_have_independent_expected_child_topology() {
|
||||
let (one, product) = binary_children(expression("1 + 2 * 3"), BinaryOp::Add);
|
||||
assert_eq!(number(&one), "1");
|
||||
let (two, three) = binary_children(product, BinaryOp::Multiply);
|
||||
assert_eq!(
|
||||
(number(&two), number(&three)),
|
||||
("2".to_owned(), "3".to_owned())
|
||||
);
|
||||
|
||||
let (conjunction, last) =
|
||||
binary_children(expression("not(false) and true or false"), BinaryOp::Or);
|
||||
assert_eq!(last.kind, ExpressionKind::Boolean(false));
|
||||
let (negated, middle) = binary_children(conjunction, BinaryOp::And);
|
||||
assert_eq!(middle.kind, ExpressionKind::Boolean(true));
|
||||
let ExpressionKind::Unary { op, operand } = negated.kind else {
|
||||
panic!("expected Not");
|
||||
};
|
||||
assert_eq!(op, UnaryOp::Not);
|
||||
assert_eq!(operand.kind, ExpressionKind::Boolean(false));
|
||||
|
||||
let (negative, two) = binary_children(expression("-1 * 2"), BinaryOp::Multiply);
|
||||
assert_eq!(number(&two), "2");
|
||||
let ExpressionKind::Unary { op, operand } = negative.kind else {
|
||||
panic!("expected Negate");
|
||||
};
|
||||
assert_eq!(op, UnaryOp::Negate);
|
||||
assert_eq!(number(&operand), "1");
|
||||
|
||||
let (quotient, three) = binary_children(expression("1 / 2 * 3"), BinaryOp::Multiply);
|
||||
assert_eq!(number(&three), "3");
|
||||
let (one, two) = binary_children(quotient, BinaryOp::Divide);
|
||||
assert_eq!(
|
||||
(number(&one), number(&two)),
|
||||
("1".to_owned(), "2".to_owned())
|
||||
);
|
||||
|
||||
let (one, grouped) = binary_children(expression("1 + (2 + 3)"), BinaryOp::Add);
|
||||
assert_eq!(number(&one), "1");
|
||||
assert!(grouped.grouped);
|
||||
let (two, three) = binary_children(grouped, BinaryOp::Add);
|
||||
assert_eq!(
|
||||
(number(&two), number(&three)),
|
||||
("2".to_owned(), "3".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children() {
|
||||
let flat = |count: usize| vec!["true"; count].join(" and ");
|
||||
let deep = flat(128);
|
||||
let mut tree = expression(&deep);
|
||||
assert_eq!(tree.depth, 128);
|
||||
// Independently verify the complete left-associated shape, not just depth.
|
||||
for _ in 1..128 {
|
||||
let (left, right) = binary_children(tree, BinaryOp::And);
|
||||
assert_eq!(right.kind, ExpressionKind::Boolean(true));
|
||||
tree = left;
|
||||
}
|
||||
assert_eq!(tree.kind, ExpressionKind::Boolean(true));
|
||||
assert!(
|
||||
parse(&flat(129), &mut budget())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("logical depth")
|
||||
);
|
||||
|
||||
// The first argument is already a complete depth128 tree when parsing the
|
||||
// second fails. Refusal tears down the held child without building Sum.
|
||||
let malformed = format!("sum({deep}, @)");
|
||||
let failure = parse(&malformed, &mut budget()).unwrap_err();
|
||||
assert_eq!(
|
||||
failure.message,
|
||||
"Expected a valid reference or parameter name."
|
||||
);
|
||||
assert_eq!(&malformed[failure.span.start..failure.span.end], ")");
|
||||
assert_eq!(number(&expression("1")), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,744 +0,0 @@
|
||||
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."
|
||||
);
|
||||
}
|
||||
@@ -1,664 +0,0 @@
|
||||
//! Private typed canonical output. Complete semantic admission belongs to the
|
||||
//! caller. No public formatter, version dispatch or source edits use this yet.
|
||||
mod expression;
|
||||
pub(super) use expression::{Parent, Side};
|
||||
|
||||
use super::{budget, lower::Field};
|
||||
use crate::analysis as a;
|
||||
use crate::analysis_numeric::Exact;
|
||||
use crate::{Id, Kind, Layer, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Limits {
|
||||
pub bytes: usize,
|
||||
pub entries: usize,
|
||||
}
|
||||
impl Limits {
|
||||
pub fn source8(caller: crate::language::ParseLimits) -> Self {
|
||||
let limits = budget::Limits::source8(caller);
|
||||
Self {
|
||||
bytes: limits.bytes,
|
||||
entries: limits.entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct Error {
|
||||
pub owner: Option<Id>,
|
||||
pub field: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) struct Usage {
|
||||
pub bytes: usize,
|
||||
pub entries: usize,
|
||||
}
|
||||
|
||||
struct Sink {
|
||||
buffer: Vec<u8>,
|
||||
written: usize,
|
||||
maximum: usize,
|
||||
}
|
||||
impl Sink {
|
||||
fn room(&self, bytes: usize) -> std::io::Result<()> {
|
||||
if bytes > self.maximum.saturating_sub(self.written) {
|
||||
return Err(std::io::Error::other(
|
||||
"Source8 writer output byte limit exceeded.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Write for Sink {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.room(bytes.len())?;
|
||||
self.buffer.extend_from_slice(bytes);
|
||||
self.written += bytes.len();
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Writer<'p> {
|
||||
project: &'p Project,
|
||||
parameters: BTreeMap<Id, BTreeMap<a::ParameterId, &'p a::Parameter>>,
|
||||
limits: Limits,
|
||||
entries: usize,
|
||||
sink: Sink,
|
||||
owner: Option<Id>,
|
||||
field: &'static str,
|
||||
failed: Option<Error>,
|
||||
}
|
||||
|
||||
impl<'p> Writer<'p> {
|
||||
pub fn new(project: &'p Project, limits: Limits) -> Result<Self, Error> {
|
||||
let hard = Limits::source8(crate::language::ParseLimits::UNLIMITED);
|
||||
if limits.bytes > hard.bytes || limits.entries > hard.entries {
|
||||
return Err(Error {
|
||||
owner: None,
|
||||
field: "writer",
|
||||
message: "Writer limits exceed the source8 profile.".into(),
|
||||
});
|
||||
}
|
||||
let mut writer = Self {
|
||||
project,
|
||||
parameters: BTreeMap::new(),
|
||||
limits,
|
||||
entries: 0,
|
||||
sink: Sink {
|
||||
buffer: Vec::new(),
|
||||
written: 0,
|
||||
maximum: limits.bytes,
|
||||
},
|
||||
owner: None,
|
||||
field: "writer",
|
||||
failed: None,
|
||||
};
|
||||
for definition in project.definitions.values() {
|
||||
writer.owner = Some(definition.id);
|
||||
writer.field = "parameters";
|
||||
writer.entry()?;
|
||||
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut names = BTreeSet::new();
|
||||
for parameter in &calculation.parameters {
|
||||
writer.entry()?;
|
||||
if parameters.contains_key(¶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;
|
||||
@@ -1,284 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Parent {
|
||||
Root,
|
||||
Binary(a::BinaryOp, Side),
|
||||
Negate,
|
||||
}
|
||||
|
||||
fn operator(op: a::BinaryOp) -> (&'static str, u8) {
|
||||
use a::BinaryOp::*;
|
||||
match op {
|
||||
Or => (" or ", 1),
|
||||
And => (" and ", 2),
|
||||
Equal => (" == ", 3),
|
||||
NotEqual => (" != ", 3),
|
||||
Less => (" < ", 4),
|
||||
LessOrEqual => (" <= ", 4),
|
||||
Greater => (" > ", 4),
|
||||
GreaterOrEqual => (" >= ", 4),
|
||||
Add => (" + ", 5),
|
||||
Subtract => (" - ", 5),
|
||||
Multiply => (" * ", 6),
|
||||
Divide => (" / ", 6),
|
||||
}
|
||||
}
|
||||
fn comparison(op: a::BinaryOp) -> bool {
|
||||
matches!(operator(op).1, 3 | 4)
|
||||
}
|
||||
fn group(expression: &a::Expr, parent: Parent) -> bool {
|
||||
let a::Expr::Binary { op, .. } = expression else {
|
||||
return false;
|
||||
};
|
||||
match parent {
|
||||
Parent::Root => false,
|
||||
Parent::Negate => true,
|
||||
Parent::Binary(parent, side) => {
|
||||
(comparison(*op) && comparison(parent))
|
||||
|| operator(*op).1 < operator(parent).1
|
||||
|| (operator(*op).1 == operator(parent).1 && matches!(side, Side::Right))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Task<'a> {
|
||||
Expression(&'a a::Expr, Parent, usize),
|
||||
Text(&'static str),
|
||||
Alias(&'a str),
|
||||
Literal(&'a Exact),
|
||||
Exponent(i8),
|
||||
}
|
||||
fn schedule<'a>(
|
||||
writer: &Writer<'_>,
|
||||
tasks: &mut Vec<Task<'a>>,
|
||||
ordered: &[Task<'a>],
|
||||
) -> Result<(), Error> {
|
||||
writer.pending(tasks.len().saturating_add(ordered.len()))?;
|
||||
tasks.extend(ordered.iter().rev().copied());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn emit_expression(
|
||||
&mut self,
|
||||
expression: &a::Expr,
|
||||
calculation: Option<Id>,
|
||||
parent: Parent,
|
||||
) -> Result<(), Error> {
|
||||
self.pending(1)?;
|
||||
let mut tasks = vec![Task::Expression(expression, parent, 1)];
|
||||
while let Some(task) = tasks.pop() {
|
||||
match task {
|
||||
Task::Text(text) => self.text(text)?,
|
||||
Task::Alias(name) => {
|
||||
self.entry()?;
|
||||
self.name(name)?;
|
||||
}
|
||||
Task::Literal(exact) => self.exact(exact)?,
|
||||
Task::Exponent(exponent) => self.text(&exponent.to_string())?,
|
||||
Task::Expression(expression, parent, depth) => {
|
||||
self.entry()?;
|
||||
if depth > a::MAX_EXPRESSION_DEPTH {
|
||||
return Err(self.error("Expression depth exceeds128."));
|
||||
}
|
||||
if group(expression, parent) {
|
||||
self.text("(")?;
|
||||
schedule(self, &mut tasks, &[Task::Text(")")])?;
|
||||
}
|
||||
use Task::{Alias, Exponent, Expression as Child, Literal, Text};
|
||||
use a::Expr as E;
|
||||
match expression {
|
||||
E::Boolean(value) => self.text(if *value { "true" } else { "false" })?,
|
||||
E::Number(value) => self.exact(value)?,
|
||||
E::Quantity { magnitude, unit } => {
|
||||
let alias = self.definition_name(*unit, DefinitionKind::Unit)?;
|
||||
schedule(
|
||||
self,
|
||||
&mut tasks,
|
||||
&[Literal(magnitude), Text(" [@"), Alias(alias), Text("]")],
|
||||
)?;
|
||||
}
|
||||
E::Property(id) => {
|
||||
let alias = self.property_name(*id)?;
|
||||
schedule(self, &mut tasks, &[Text("@"), Alias(alias)])?;
|
||||
}
|
||||
E::Parameter(parameter) => {
|
||||
let calculation = calculation.ok_or_else(|| {
|
||||
self.error("Parameter use requires an owning calculation.")
|
||||
})?;
|
||||
let parameter = *self
|
||||
.parameters
|
||||
.get(&calculation)
|
||||
.and_then(|parameters| parameters.get(parameter))
|
||||
.ok_or_else(|| {
|
||||
self.error("Unknown parameter in owning calculation.")
|
||||
})?;
|
||||
schedule(self, &mut tasks, &[Text("$"), Alias(¶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(())
|
||||
}
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
use super::super::{cursor::Cursor, declarations, lex, lower, model_oracle};
|
||||
use super::*;
|
||||
use crate::language::ParseLimits;
|
||||
use a::{
|
||||
AnalysisType, BinaryOp, Binding, DefinitionBody, DefinitionRef, Expr, ParameterId, ScalarType,
|
||||
UnaryOp,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::source8(ParseLimits::UNLIMITED)
|
||||
}
|
||||
fn writer(project: &Project) -> Writer<'_> {
|
||||
Writer::new(project, limits()).unwrap()
|
||||
}
|
||||
fn id(text: &str) -> Id {
|
||||
Id::parse_str(text).unwrap()
|
||||
}
|
||||
fn object(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn number(value: i64) -> Expr {
|
||||
Expr::Number(Exact::from_integer(value))
|
||||
}
|
||||
fn unary(op: UnaryOp, value: Expr) -> Expr {
|
||||
Expr::Unary {
|
||||
op,
|
||||
operand: Box::new(value),
|
||||
}
|
||||
}
|
||||
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
|
||||
Expr::Binary {
|
||||
op,
|
||||
left: Box::new(left),
|
||||
right: Box::new(right),
|
||||
}
|
||||
}
|
||||
fn quantity(value: i64, unit: Id) -> Expr {
|
||||
Expr::Quantity {
|
||||
magnitude: Exact::from_integer(value),
|
||||
unit: DefinitionRef::Local(unit),
|
||||
}
|
||||
}
|
||||
fn budget() -> budget::Budget {
|
||||
budget::Budget::new(budget::Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||
}
|
||||
fn unit(source: &str, budget: &mut budget::Budget) -> Result<lower::Unit, super::super::Error> {
|
||||
let tokens = lex::tokens(source, budget)?;
|
||||
let mut cursor = Cursor::new(source, &tokens, budget);
|
||||
let mut parsed = Vec::new();
|
||||
while cursor.cursor < tokens.len() {
|
||||
parsed.push(declarations::parse(&mut cursor, 8, None)?);
|
||||
}
|
||||
Ok(lower::Unit {
|
||||
file: 1,
|
||||
declarations: parsed,
|
||||
})
|
||||
}
|
||||
fn merge(mut project: Project, lowered: lower::Lowered) -> Project {
|
||||
project.definitions.extend(lowered.definitions);
|
||||
project.elements.extend(lowered.elements);
|
||||
project
|
||||
}
|
||||
fn parse_typed(project: Project, source: &str) -> Project {
|
||||
let mut budget = budget();
|
||||
let unit = unit(source, &mut budget).unwrap();
|
||||
let lowered = lower::lower(&project, &[unit], &budget).unwrap();
|
||||
merge(project, lowered)
|
||||
}
|
||||
fn skeleton(project: &Project) -> Project {
|
||||
let mut project = project.clone();
|
||||
project.definitions.clear();
|
||||
project
|
||||
.elements
|
||||
.retain(|_, element| !element.kind().is_analysis());
|
||||
project
|
||||
}
|
||||
fn canonical(project: &Project) -> String {
|
||||
writer(project)
|
||||
.typed_declarations()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, text)| text)
|
||||
.collect()
|
||||
}
|
||||
fn read_expression(project: &Project, source: &str) -> Expr {
|
||||
// A staged syntax/lowering wrapper, not a whole public source8 parser.
|
||||
let source = format!(
|
||||
r#"calculation writer_probe "Probe" id "00000000-0000-0000-0000-000000000099" {{
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Number; body {source};
|
||||
}}"#
|
||||
);
|
||||
let mut parsed = parse_typed(project.clone(), &source);
|
||||
let definition = parsed.definitions.remove(&Id::from_u128(0x99)).unwrap();
|
||||
let DefinitionBody::Calculation(calculation) = definition.body else {
|
||||
panic!("calculation")
|
||||
};
|
||||
let Binding::Expression(expression) = calculation.body else {
|
||||
panic!("expression")
|
||||
};
|
||||
expression
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_oa_sa_canonical_roundtrips_match_independent_projects() {
|
||||
for (layer, ordinary_source) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
include_str!("../lower/fixtures/ordinary-oa.arcadia"),
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
|
||||
),
|
||||
] {
|
||||
let expected = model_oracle::project(layer);
|
||||
for (alias, name) in model_oracle::NAMES {
|
||||
let id = object(&expected, alias);
|
||||
assert_eq!(
|
||||
expected
|
||||
.definitions
|
||||
.get(&id)
|
||||
.map(|value| value.name.as_str())
|
||||
.or_else(|| expected.elements.get(&id).map(|value| value.name.as_str()))
|
||||
.unwrap(),
|
||||
name
|
||||
);
|
||||
}
|
||||
let text = canonical(&expected);
|
||||
let actual = parse_typed(crate::language::parse(ordinary_source).unwrap(), &text);
|
||||
assert_eq!(actual, expected);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
assert_eq!(canonical(&actual), text);
|
||||
assert!(
|
||||
!text.contains("engineering_property")
|
||||
&& !text.contains("analysis_case")
|
||||
&& !text.contains("analysis_check")
|
||||
);
|
||||
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
|
||||
for word in ["property", "check", "analysis"] {
|
||||
assert!(text.contains(&format!("{prefix}_{word} ")));
|
||||
}
|
||||
}
|
||||
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_declaration_order_and_required_empty_values_are_canonical() {
|
||||
let mut project = model_oracle::project(Layer::Oa);
|
||||
let duration = object(&project, "duration");
|
||||
let total = object(&project, "total_time");
|
||||
if let Some(SharedBody::EngineeringProperty { display_unit, .. }) =
|
||||
project.elements.get_mut(&total).unwrap().body.shared_mut()
|
||||
{
|
||||
*display_unit = None;
|
||||
}
|
||||
let empty_case = object(&project, "unmeasured");
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
|
||||
.elements
|
||||
.get_mut(&empty_case)
|
||||
.unwrap()
|
||||
.body
|
||||
.shared_mut()
|
||||
{
|
||||
overrides.clear();
|
||||
}
|
||||
let degraded = object(&project, "degraded");
|
||||
let dispatch = object(&project, "dispatch_time");
|
||||
let second = object(&project, "second");
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
|
||||
.elements
|
||||
.get_mut(°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);
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
//! One raw lexical scan for legacy adapters and staged typed source syntax.
|
||||
//! Numbers remain source spans; only consumers choose a numeric domain.
|
||||
use super::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Class {
|
||||
Word,
|
||||
Number,
|
||||
String,
|
||||
Punctuation,
|
||||
Whitespace,
|
||||
Comment,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Token {
|
||||
pub class: Class,
|
||||
pub span: Span,
|
||||
/// Entire token belongs to the old greedy numeric-token alphabet.
|
||||
pub legacy_number_part: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Scanner<'a> {
|
||||
source: &'a str,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> Scanner<'a> {
|
||||
pub fn new(source: &'a str) -> Self {
|
||||
Self { source, cursor: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Scanner<'_> {
|
||||
type Item = Token;
|
||||
|
||||
fn next(&mut self) -> Option<Token> {
|
||||
let source = self.source;
|
||||
let bytes = source.as_bytes();
|
||||
let start = self.cursor;
|
||||
let first = *bytes.get(start)?;
|
||||
let mut legacy_number_part = false;
|
||||
let class = if matches!(first, b' ' | b'\t' | b'\r' | b'\n') {
|
||||
self.cursor += 1;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
Class::Whitespace
|
||||
} else if bytes[start..].starts_with(b"//") {
|
||||
self.cursor += 2;
|
||||
while bytes.get(self.cursor).is_some_and(|b| *b != b'\n') {
|
||||
self.cursor += 1;
|
||||
}
|
||||
Class::Comment
|
||||
} else if first.is_ascii_alphabetic() || first == b'_' {
|
||||
self.cursor += 1;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
legacy_number_part = true;
|
||||
Class::Word
|
||||
} else if first.is_ascii_digit() {
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let mut valid = true;
|
||||
if bytes.get(self.cursor) == Some(&b'.') {
|
||||
self.cursor += 1;
|
||||
let fraction = self.cursor;
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
valid &= self.cursor != fraction;
|
||||
}
|
||||
if matches!(bytes.get(self.cursor), Some(b'e' | b'E')) {
|
||||
self.cursor += 1;
|
||||
if matches!(bytes.get(self.cursor), Some(b'+' | b'-')) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
let exponent = self.cursor;
|
||||
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
valid &= self.cursor != exponent;
|
||||
}
|
||||
if bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'.'))
|
||||
{
|
||||
valid = false;
|
||||
while bytes
|
||||
.get(self.cursor)
|
||||
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.'))
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
legacy_number_part = true;
|
||||
if valid { Class::Number } else { Class::Error }
|
||||
} else if first == b'"' {
|
||||
self.cursor += 1;
|
||||
let mut escaped = false;
|
||||
let mut closed = false;
|
||||
while self.cursor < bytes.len() {
|
||||
let ch = source[self.cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("UTF-8 boundary");
|
||||
self.cursor += ch.len_utf8();
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed && serde_json::from_str::<String>(&source[start..self.cursor]).is_ok() {
|
||||
Class::String
|
||||
} else {
|
||||
Class::Error
|
||||
}
|
||||
} else if b"{}();,[]:=@$+-*/<>!".contains(&first) {
|
||||
self.cursor += 1;
|
||||
if matches!(first, b'<' | b'>' | b'=' | b'!') && bytes.get(self.cursor) == Some(&b'=') {
|
||||
self.cursor += 1;
|
||||
}
|
||||
legacy_number_part = matches!(first, b'+' | b'-');
|
||||
Class::Punctuation
|
||||
} else {
|
||||
self.cursor += source[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("UTF-8 boundary")
|
||||
.len_utf8();
|
||||
legacy_number_part = first == b'.';
|
||||
Class::Error
|
||||
};
|
||||
Some(Token {
|
||||
class,
|
||||
span: Span {
|
||||
start,
|
||||
end: self.cursor,
|
||||
},
|
||||
legacy_number_part,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the existing public token shapes, including greedy signed JSON
|
||||
/// numbers and single-character rejection of newer expression operators.
|
||||
pub(super) fn legacy(source: &str) -> Vec<super::TokenSpan> {
|
||||
use super::{TokenClass, TokenSpan};
|
||||
let mut scanner = Scanner::new(source).peekable();
|
||||
let mut out = Vec::new();
|
||||
while let Some(token) = scanner.next() {
|
||||
let mut span = token.span;
|
||||
let first = source.as_bytes()[span.start];
|
||||
if first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.') {
|
||||
while scanner
|
||||
.peek()
|
||||
.is_some_and(|next| next.legacy_number_part && next.span.start == span.end)
|
||||
{
|
||||
span.end = scanner.next().expect("peeked token").span.end;
|
||||
}
|
||||
let class = if serde_json::from_str::<serde_json::Number>(&source[span.start..span.end])
|
||||
.is_ok()
|
||||
{
|
||||
TokenClass::Number
|
||||
} else {
|
||||
TokenClass::Error
|
||||
};
|
||||
out.push(TokenSpan { class, span });
|
||||
continue;
|
||||
}
|
||||
let class = match token.class {
|
||||
Class::Word => {
|
||||
let word = &source[span.start..span.end];
|
||||
if super::reserved(word) || matches!(word, "true" | "false" | "none") {
|
||||
TokenClass::Keyword
|
||||
} else {
|
||||
TokenClass::Identifier
|
||||
}
|
||||
}
|
||||
Class::String => TokenClass::String,
|
||||
Class::Whitespace => TokenClass::Whitespace,
|
||||
Class::Comment => TokenClass::Comment,
|
||||
Class::Punctuation => {
|
||||
// The raw scanner joins comparison operators. Legacy syntax
|
||||
// classified their ASCII characters individually.
|
||||
for at in span.start..span.end {
|
||||
let class = if b"{}();,[]:=@".contains(&source.as_bytes()[at]) {
|
||||
TokenClass::Punctuation
|
||||
} else {
|
||||
TokenClass::Error
|
||||
};
|
||||
out.push(TokenSpan {
|
||||
class,
|
||||
span: Span {
|
||||
start: at,
|
||||
end: at + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Class::Error => TokenClass::Error,
|
||||
Class::Number => unreachable!("number tokens start with a digit"),
|
||||
};
|
||||
out.push(TokenSpan { class, span });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{TokenClass, tokenize};
|
||||
|
||||
#[test]
|
||||
fn legacy_adapter_retains_authored_numeric_operator_and_trivia_tokens() {
|
||||
use TokenClass::*;
|
||||
let cases: &[(&str, &[(TokenClass, &str)])] = &[
|
||||
(
|
||||
"-1.5 1e-2 -0",
|
||||
&[
|
||||
(Number, "-1.5"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1e-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "-0"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"1-2 1 -2 1 - 2",
|
||||
&[
|
||||
(Error, "1-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1"),
|
||||
(Whitespace, " "),
|
||||
(Number, "-2"),
|
||||
(Whitespace, " "),
|
||||
(Number, "1"),
|
||||
(Whitespace, " "),
|
||||
(Error, "-"),
|
||||
(Whitespace, " "),
|
||||
(Number, "2"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"+1 .5 1_0 0x1 1e 1e+",
|
||||
&[
|
||||
(Error, "+1"),
|
||||
(Whitespace, " "),
|
||||
(Error, ".5"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1_0"),
|
||||
(Whitespace, " "),
|
||||
(Error, "0x1"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1e"),
|
||||
(Whitespace, " "),
|
||||
(Error, "1e+"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"<= != == >= / * $ !",
|
||||
&[
|
||||
(Error, "<"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, "!"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Punctuation, "="),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, ">"),
|
||||
(Punctuation, "="),
|
||||
(Whitespace, " "),
|
||||
(Error, "/"),
|
||||
(Whitespace, " "),
|
||||
(Error, "*"),
|
||||
(Whitespace, " "),
|
||||
(Error, "$"),
|
||||
(Whitespace, " "),
|
||||
(Error, "!"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"// π\r\n\"雪\" @alias",
|
||||
&[
|
||||
(Comment, "// π\r"),
|
||||
(Whitespace, "\n"),
|
||||
(String, "\"雪\""),
|
||||
(Whitespace, " "),
|
||||
(Punctuation, "@"),
|
||||
(Keyword, "alias"),
|
||||
],
|
||||
),
|
||||
("\"incomplete 雪", &[(Error, "\"incomplete 雪")]),
|
||||
("\"bad\\q\"", &[(Error, "\"bad\\q\"")]),
|
||||
(
|
||||
"雪\u{b}a",
|
||||
&[(Error, "雪"), (Error, "\u{b}"), (Identifier, "a")],
|
||||
),
|
||||
(
|
||||
"unit calculation quantity_kind if and List Number",
|
||||
&[
|
||||
(Identifier, "unit"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "calculation"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "quantity_kind"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "if"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "and"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "List"),
|
||||
(Whitespace, " "),
|
||||
(Identifier, "Number"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (source, expected) in cases {
|
||||
let actual = tokenize(source);
|
||||
assert_eq!(actual.len(), expected.len(), "{source:?}");
|
||||
let mut offset = 0;
|
||||
for (token, (class, spelling)) in actual.iter().zip(*expected) {
|
||||
assert_eq!(token.class, *class, "{source:?} at {offset}");
|
||||
assert_eq!(token.span.start, offset);
|
||||
offset += spelling.len();
|
||||
assert_eq!(token.span.end, offset);
|
||||
assert_eq!(&source[token.span.start..token.span.end], *spelling);
|
||||
}
|
||||
assert_eq!(offset, source.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,8 +194,6 @@ 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()?;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
//! Convert only source3 lowering scratch state into its frozen migration input.
|
||||
//! This is not a current-to-legacy exporter: fields outside that scratch state
|
||||
//! must reject instead of being silently discarded.
|
||||
use crate::Project;
|
||||
use crate::persistence::{FileError, v3};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn from_scratch(
|
||||
project: Project,
|
||||
legacy_elements: Map<String, Value>,
|
||||
) -> Result<v3::Project3, FileError> {
|
||||
// No `..`: a future Project field requires an explicit compatibility decision.
|
||||
let Project {
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
definitions,
|
||||
elements,
|
||||
relations,
|
||||
diagrams,
|
||||
artifact_links,
|
||||
traces,
|
||||
} = project;
|
||||
if schema_version != 3 {
|
||||
return Err(FileError::Invalid(
|
||||
"Expected source3 lowering state.".into(),
|
||||
));
|
||||
}
|
||||
if !definitions.is_empty() || !elements.is_empty() {
|
||||
return Err(FileError::Invalid(
|
||||
"Source3 lowering state cannot contain current definitions or elements.".into(),
|
||||
));
|
||||
}
|
||||
Ok(v3::Project3 {
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
elements: serde_json::from_value(Value::Object(legacy_elements))?,
|
||||
// Keep the frozen nested contracts, including strict trace baselines.
|
||||
relations: serde_json::from_value(serde_json::to_value(relations)?)?,
|
||||
diagrams,
|
||||
artifact_links,
|
||||
traces: serde_json::from_value(serde_json::to_value(traces)?)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,188 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{Id, language, persistence};
|
||||
use serde_json::json;
|
||||
|
||||
const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia");
|
||||
const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json");
|
||||
const PROSE: &str = r#"arcadia 3;
|
||||
project review "Review" id "00000000-0000-0000-0000-000000000001" {
|
||||
oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {}
|
||||
sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {}
|
||||
trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" {
|
||||
oa @reviewed_activity; sa @function; kind Refinement;
|
||||
baseline {
|
||||
oa { name "Approved OA"; description "First\nΩ rationale"; };
|
||||
sa { name "Approved SA"; description "Prior SA prose"; };
|
||||
};
|
||||
}
|
||||
}"#;
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn prose_json() -> Value {
|
||||
json!({
|
||||
"schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"",
|
||||
"elements": {
|
||||
id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}},
|
||||
id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}}
|
||||
},
|
||||
"relations":{},"diagrams":{},"artifact_links":{},
|
||||
"traces": {id(4).to_string():{
|
||||
"id":id(4),"alias":"review_link","name":"Review link","description":"",
|
||||
"oa":id(2),"sa":id(3),"kind":"Refinement",
|
||||
"baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}}
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_source3_and_json3_have_complete_model_and_migration_parity() {
|
||||
let source = language::parse_with_report(SOURCE3).unwrap();
|
||||
let json = persistence::decode_with_report(JSON3).unwrap();
|
||||
assert_eq!(source.project, json.project);
|
||||
assert_eq!(source.migration, json.migration);
|
||||
assert!(source.project.definitions.is_empty());
|
||||
let report = source.migration.unwrap();
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(3, crate::SCHEMA_VERSION)
|
||||
);
|
||||
// This tests direct legacy lowering. Canonical source8 reopening remains a
|
||||
// separate, unchanged public integration gate.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() {
|
||||
let original: Value = serde_json::from_slice(JSON3).unwrap();
|
||||
for definitions in [
|
||||
json!({}),
|
||||
Value::Null,
|
||||
json!({id(90).to_string(): {"name":"new"}}),
|
||||
] {
|
||||
let mut bad = original.clone();
|
||||
bad["definitions"] = definitions;
|
||||
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(error.contains("unknown field `definitions`"), "{error}");
|
||||
}
|
||||
let mut bad = original;
|
||||
bad["unrecognized"] = json!(true);
|
||||
assert!(
|
||||
persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("unknown field `unrecognized`")
|
||||
);
|
||||
let raw = std::str::from_utf8(JSON3).unwrap();
|
||||
let duplicate = raw.replacen(
|
||||
"\"schema_version\": 3",
|
||||
"\"schema_version\": 3, \"schema_version\": 3",
|
||||
1,
|
||||
);
|
||||
assert_ne!(duplicate, raw);
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("duplicate")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_review_prose_survives_without_admitting_semantic_fields() {
|
||||
let source = language::parse_with_report(PROSE).unwrap();
|
||||
let json =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap();
|
||||
assert_eq!(source.project, json.project);
|
||||
assert_eq!(source.migration, json.migration);
|
||||
let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap();
|
||||
assert_eq!(baseline.oa.name, "Approved OA");
|
||||
assert_eq!(baseline.oa.description, "First\nΩ rationale");
|
||||
assert_eq!(baseline.sa.description, "Prior SA prose");
|
||||
assert!(baseline.semantic.is_none());
|
||||
for semantic in [Value::Null, json!({"facets":[]})] {
|
||||
let mut bad = prose_json();
|
||||
bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic;
|
||||
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(error.contains("semantic"), "{error}");
|
||||
}
|
||||
for field in ["semantic none;", "semantic { facets []; };"] {
|
||||
let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}"));
|
||||
let errors = language::parse(&bad).err().unwrap();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("require schema/source5")),
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_bridge_refuses_to_discard_current_only_data() {
|
||||
let mut empty = Project::empty();
|
||||
empty.schema_version = 3;
|
||||
assert!(from_scratch(empty.clone(), Map::new()).is_ok());
|
||||
let mut wrong_version = empty.clone();
|
||||
wrong_version.schema_version = crate::SCHEMA_VERSION;
|
||||
assert!(from_scratch(wrong_version, Map::new()).is_err());
|
||||
let mut definition = empty;
|
||||
definition.definitions.insert(
|
||||
id(90),
|
||||
crate::analysis::Definition {
|
||||
id: id(90),
|
||||
alias: "dimensionless".into(),
|
||||
name: "Dimensionless".into(),
|
||||
description: String::new(),
|
||||
body: crate::analysis::DefinitionBody::QuantityKind(
|
||||
crate::analysis::QuantityKindDefinition {
|
||||
dimension: crate::analysis_numeric::Dimension::ZERO,
|
||||
algebra: crate::analysis::QuantityAlgebra::Linear,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
from_scratch(definition, Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("definitions or elements")
|
||||
);
|
||||
let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap())
|
||||
.unwrap()
|
||||
.project;
|
||||
current.schema_version = 3;
|
||||
assert!(
|
||||
from_scratch(current.clone(), Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("definitions or elements")
|
||||
);
|
||||
current.elements.clear();
|
||||
current
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() });
|
||||
assert!(
|
||||
from_scratch(current, Map::new())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("semantic")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Prepared outline tests. Explicit compatibility tests additionally exercise
|
||||
//! the existing parsers/analyze; the outline entry itself never calls them.
|
||||
use super::*;
|
||||
|
||||
fn limits() -> OutlineLimits {
|
||||
OutlineLimits {
|
||||
max_source_bytes: 1024 * 1024,
|
||||
max_tokens: 100_000,
|
||||
max_declarations: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
|
||||
result
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.declaration.alias == alias)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
|
||||
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
|
||||
let before = source.as_bytes().to_vec();
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source.as_bytes(), before);
|
||||
assert_eq!(result.source.as_ptr(), source.as_ptr());
|
||||
let d = declaration(&result, "responder");
|
||||
assert_eq!(text(source, d.kind_span), "oa_actor");
|
||||
assert_eq!(text(source, d.alias_span), "responder");
|
||||
assert_eq!(
|
||||
text(source, d.id_literal_span.unwrap()),
|
||||
"\"\\u00300000000-0000-0000-0000-000000000001\""
|
||||
);
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
|
||||
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
|
||||
let mut end = 0;
|
||||
for token in &result.tokens {
|
||||
assert_eq!(token.span.start, end);
|
||||
assert!(token.span.start < token.span.end);
|
||||
assert!(source.is_char_boundary(token.span.start));
|
||||
assert!(source.is_char_boundary(token.span.end));
|
||||
end = token.span.end;
|
||||
}
|
||||
assert_eq!(end, source.len());
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_id_at_eof_is_inside_the_declaration_extent() {
|
||||
for source in [
|
||||
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
|
||||
"node placed id \"00000000-0000-0000-0000-000000000001\"",
|
||||
] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
let literal = d.id_literal_span.unwrap();
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert!(d.declaration.span.start <= literal.start);
|
||||
assert!(literal.end <= d.declaration.span.end);
|
||||
}
|
||||
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(d.id_literal_span, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
|
||||
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
|
||||
let first = outline(source, limits()).unwrap();
|
||||
let second = outline(source, limits()).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
first
|
||||
.declarations
|
||||
.iter()
|
||||
.all(|d| d.declaration.id.is_none())
|
||||
);
|
||||
assert_eq!(declaration(&first, "missing").id_literal_span, None);
|
||||
assert_eq!(declaration(&first, "historical").id_literal_span, None);
|
||||
assert_eq!(
|
||||
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
|
||||
"\"not-a-uuid\""
|
||||
);
|
||||
// Balanced tokens are not a successful syntax/semantic validation claim.
|
||||
assert!(!first.has_lexical_errors);
|
||||
assert!(!first.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
|
||||
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source, source);
|
||||
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
|
||||
assert!(result.has_lexical_errors);
|
||||
assert!(result.has_unmatched_braces);
|
||||
let ready = declaration(&result, "ready");
|
||||
assert_eq!(
|
||||
text(source, ready.declaration.span),
|
||||
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "outer").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "pending").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
|
||||
assert!(unmatched_close.has_unmatched_braces);
|
||||
assert!(!unmatched_close.has_lexical_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
|
||||
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 1);
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
// There is deliberately no `valid` flag: the known-complete fact is scanning
|
||||
// these bytes, not the missing body becoming legal or the draft being applied.
|
||||
assert_eq!(result.source, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
|
||||
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 5);
|
||||
let duplicates: Vec<_> = result
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|d| d.declaration.alias == "oa_activity")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
|
||||
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
|
||||
assert!(
|
||||
result
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
|
||||
);
|
||||
assert_eq!(
|
||||
text(source, declaration(&result, "placement").kind_span),
|
||||
"node"
|
||||
);
|
||||
assert_eq!(declaration(&result, "placement").declaration.id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_refuse_before_returning_a_partial_outline() {
|
||||
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
|
||||
let admitted = outline(source, limits()).unwrap();
|
||||
let exact = OutlineLimits {
|
||||
max_source_bytes: source.len(),
|
||||
max_tokens: admitted.tokens.len(),
|
||||
max_declarations: 2,
|
||||
};
|
||||
assert_eq!(outline(source, exact).unwrap(), admitted);
|
||||
for resource in [
|
||||
OutlineResource::SourceBytes,
|
||||
OutlineResource::Tokens,
|
||||
OutlineResource::Declarations,
|
||||
] {
|
||||
let mut lower = exact;
|
||||
match resource {
|
||||
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
|
||||
OutlineResource::Tokens => lower.max_tokens -= 1,
|
||||
OutlineResource::Declarations => lower.max_declarations -= 1,
|
||||
}
|
||||
let error = outline(source, lower).unwrap_err();
|
||||
assert_eq!(error.resource, resource);
|
||||
assert!(error.observed_at_least > error.limit);
|
||||
assert!(source.is_char_boundary(error.offset));
|
||||
if resource == OutlineResource::Declarations {
|
||||
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
|
||||
}
|
||||
}
|
||||
let empty = outline(
|
||||
"",
|
||||
OutlineLimits {
|
||||
max_source_bytes: 0,
|
||||
max_tokens: 0,
|
||||
max_declarations: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
|
||||
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_source_bytes: 4,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::SourceBytes);
|
||||
assert_eq!(error.offset, 0);
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_tokens: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Tokens);
|
||||
assert_eq!(error.offset, unicode.find('\n').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
|
||||
let old = "oa_actor a \"A\" {}\nsource @a;";
|
||||
let current = format!("// 航海 🚒\r\n{old}");
|
||||
let old_outline = outline(old, limits()).unwrap();
|
||||
let current_outline = outline(¤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,6 +1,4 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis;
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
|
||||
@@ -75,7 +75,6 @@ impl Project4 {
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
|
||||
//! Unchanged nested records are shared only after the closed old-kind check.
|
||||
use crate::persistence::FileError;
|
||||
use crate::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project7 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
impl Project7 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
if !(5..=7).contains(&self.schema_version) {
|
||||
return Err(FileError::Invalid(
|
||||
"Expected frozen schema5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
reject_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: self.schema_version,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self.traces,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn reject_elements<'a>(
|
||||
elements: impl Iterator<Item = &'a Element>,
|
||||
) -> Result<(), FileError> {
|
||||
for element in elements {
|
||||
if element.kind().is_analysis() {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"{:?} requires schema/source 8.",
|
||||
element.kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 8;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -64,12 +64,9 @@ pub enum Kind {
|
||||
Terminate,
|
||||
Event,
|
||||
Constraint,
|
||||
EngineeringProperty,
|
||||
AnalysisCase,
|
||||
AnalysisCheck,
|
||||
}
|
||||
impl Kind {
|
||||
pub const ALL: [Self; 42] = [
|
||||
pub const ALL: [Self; 39] = [
|
||||
Self::Entity,
|
||||
Self::Actor,
|
||||
Self::Role,
|
||||
@@ -109,9 +106,6 @@ impl Kind {
|
||||
Self::Terminate,
|
||||
Self::Event,
|
||||
Self::Constraint,
|
||||
Self::EngineeringProperty,
|
||||
Self::AnalysisCase,
|
||||
Self::AnalysisCheck,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -154,9 +148,6 @@ impl Kind {
|
||||
Self::Terminate => "Terminate",
|
||||
Self::Event => "Event",
|
||||
Self::Constraint => "Constraint",
|
||||
Self::EngineeringProperty => "Engineering property",
|
||||
Self::AnalysisCase => "Analysis case",
|
||||
Self::AnalysisCheck => "Analysis check",
|
||||
}
|
||||
}
|
||||
pub fn is_performer(self) -> bool {
|
||||
@@ -165,9 +156,6 @@ impl Kind {
|
||||
Self::Entity | Self::Actor | Self::System | Self::SystemActor
|
||||
)
|
||||
}
|
||||
pub fn is_analysis(self) -> bool {
|
||||
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
|
||||
}
|
||||
pub fn is_work(self) -> bool {
|
||||
matches!(self, Self::Activity | Self::Function)
|
||||
}
|
||||
@@ -664,23 +652,6 @@ pub enum SharedBody {
|
||||
expression: String,
|
||||
constrained: Vec<Id>,
|
||||
},
|
||||
EngineeringProperty {
|
||||
subject: crate::analysis::AnalysisSubject,
|
||||
value_type: crate::analysis::AnalysisType,
|
||||
display_unit: Option<crate::analysis::DefinitionRef>,
|
||||
base: crate::analysis::Binding,
|
||||
},
|
||||
AnalysisCase {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
base: crate::analysis::BaselineBase,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
overrides: BTreeMap<Id, crate::analysis::Binding>,
|
||||
},
|
||||
AnalysisCheck {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
documented_constraint: Option<Id>,
|
||||
predicate: crate::analysis::Binding,
|
||||
},
|
||||
}
|
||||
impl Element {
|
||||
pub fn layer(&self) -> Layer {
|
||||
@@ -781,8 +752,7 @@ impl ElementBody {
|
||||
}
|
||||
}
|
||||
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
|
||||
// Analysis creation requires explicitly authored types and bindings.
|
||||
if !kind.supports(layer) || kind.is_analysis() {
|
||||
if !kind.supports(layer) {
|
||||
return None;
|
||||
}
|
||||
Some(match kind {
|
||||
@@ -894,9 +864,6 @@ impl SharedBody {
|
||||
Self::Region => Kind::Region,
|
||||
Self::State { kind, .. } => kind.kind(),
|
||||
Self::Constraint { .. } => Kind::Constraint,
|
||||
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
|
||||
Self::AnalysisCase { .. } => Kind::AnalysisCase,
|
||||
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1658,8 +1625,6 @@ pub struct Project {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
@@ -1752,7 +1717,6 @@ impl Project {
|
||||
alias: "mission".into(),
|
||||
name: "Untitled mission".into(),
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1762,7 +1726,6 @@ impl Project {
|
||||
}
|
||||
pub fn identities(&self) -> Vec<(Id, &str)> {
|
||||
let mut out = vec![(self.id, self.alias.as_str())];
|
||||
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
|
||||
@@ -1820,9 +1783,6 @@ impl Project {
|
||||
if id == self.id {
|
||||
return &self.name;
|
||||
}
|
||||
if let Some(v) = self.definitions.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
if let Some(v) = self.elements.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
mod json_codec;
|
||||
#[cfg(test)]
|
||||
#[path = "persistence/source_save_tests.rs"]
|
||||
mod source_save_tests;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
@@ -13,8 +9,6 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
#[path = "migration_v7.rs"]
|
||||
pub(crate) mod v7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -136,19 +130,6 @@ pub fn decode_limited(
|
||||
pub fn decode_with_report_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report_profile(
|
||||
bytes,
|
||||
limits,
|
||||
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn decode_with_report_profile(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
@@ -157,43 +138,65 @@ fn decode_with_report_profile(
|
||||
)));
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
json_codec::preflight(bytes, profile)?;
|
||||
let version = json_codec::version(bytes)?;
|
||||
if version.is_some_and(|version| version <= 6) {
|
||||
let header = json_codec::legacy(bytes)?;
|
||||
let result = check_legacy_header(&header, version);
|
||||
json_codec::discard_value(header);
|
||||
result?;
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
}
|
||||
match version {
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
Some(1) => {
|
||||
let old = json_codec::legacy(bytes)?;
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(6) => migrate_v6(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(7) => migrate_v7(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(8) => DecodeResult {
|
||||
project: json_codec::current(bytes, profile, depth_128)?,
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
|
||||
.into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -222,38 +225,6 @@ fn decode_with_report_profile(
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
|
||||
reject_source7_fields(header).map_err(FileError::Invalid)?;
|
||||
if version.is_some_and(|version| version <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Versions 4 and 5 share unchanged typed records, but their constructor set
|
||||
/// remains frozen. Apply this before normalizing the advertised version.
|
||||
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
|
||||
@@ -377,7 +348,6 @@ pub(crate) fn migrate_v5(
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
@@ -398,49 +368,48 @@ pub(crate) fn migrate_v5(
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str)
|
||||
== Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
pending.extend(fields.values().rev());
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
pending.extend(values.iter().rev());
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -451,7 +420,6 @@ pub(crate) fn migrate_v6(
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
@@ -461,30 +429,6 @@ pub(crate) fn migrate_v6(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v7(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 7 {
|
||||
return Err(FileError::Invalid("Expected schema_version 7.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(7)),
|
||||
})
|
||||
}
|
||||
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
|
||||
if !project.definitions.is_empty() {
|
||||
return Err(FileError::Invalid(
|
||||
"Definitions require schema/source 8.".into(),
|
||||
));
|
||||
}
|
||||
v7::reject_elements(project.elements.values())
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
@@ -902,9 +846,6 @@ pub(crate) mod v1 {
|
||||
}
|
||||
|
||||
fn validate_project(p: &Project) -> Result<(), FileError> {
|
||||
json_codec::document_stack(|| validate_project_inner(p))
|
||||
}
|
||||
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
|
||||
if let Some(i) = validation::validate(p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
@@ -920,10 +861,7 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
load_with_report(path).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report(&json_codec::read_bounded(
|
||||
path,
|
||||
json_codec::MAX_DOCUMENT_BYTES,
|
||||
)?)
|
||||
decode_with_report(&std::fs::read(path)?)
|
||||
}
|
||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||
@@ -936,69 +874,19 @@ pub fn save_checked(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
save_checked_profile(
|
||||
path,
|
||||
p,
|
||||
expected,
|
||||
json_codec::Profile::DOCUMENT,
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn save_checked_profile(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
|
||||
crate::language::format(project).into_bytes()
|
||||
})
|
||||
}
|
||||
// Keep the source writer replaceable only at this private seam so regressions
|
||||
// can prove that malformed or lossy output never reaches filesystem staging.
|
||||
fn save_checked_profile_with_source(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
source: impl FnOnce(&Project) -> Vec<u8>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
validate_project(p)?;
|
||||
let is_source = path
|
||||
let mut bytes = if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
||||
let mut bytes = if is_source {
|
||||
source(p)
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
crate::language::format(p).into_bytes()
|
||||
} else {
|
||||
json_codec::encode(p, profile, depth_128)?
|
||||
serde_json::to_vec_pretty(p)?
|
||||
};
|
||||
if !bytes.ends_with(b"\n") {
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
if is_source {
|
||||
// Check exactly the final bytes, including their newline, through the
|
||||
// public reader before creating a temp file or replacing a destination.
|
||||
let decoded = decode_with_report_limited(
|
||||
&bytes,
|
||||
crate::language::ParseLimits {
|
||||
max_source_bytes: profile.bytes,
|
||||
..crate::language::ParseLimits::UNLIMITED
|
||||
},
|
||||
)?;
|
||||
if decoded.project != *p {
|
||||
return Err(FileError::Invalid(
|
||||
"Generated source does not reproduce the complete requested project.".into(),
|
||||
));
|
||||
}
|
||||
if decoded.migration.is_some() {
|
||||
return Err(FileError::Invalid(
|
||||
"Generated source unexpectedly requires migration.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
@@ -1007,8 +895,8 @@ fn save_checked_profile_with_source(
|
||||
temp.write_all(&bytes)?;
|
||||
temp.as_file().sync_all()?;
|
||||
if let Some(expected) = expected {
|
||||
match json_codec::matches_file(path, expected) {
|
||||
Ok(true) => {}
|
||||
match std::fs::read(path) {
|
||||
Ok(current) if current == expected => {}
|
||||
Ok(_) => return Err(FileError::Conflict),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
|
||||
Err(e) => return Err(FileError::Io(e)),
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
//! Private persistence JSON boundary. Scan original bytes before Serde allocates;
|
||||
//! the scanner is a resource meter, never a second accepting JSON grammar.
|
||||
use super::FileError;
|
||||
use crate::{
|
||||
Project,
|
||||
analysis::expression_serde::{self, Scope},
|
||||
};
|
||||
use serde::{
|
||||
Deserialize,
|
||||
de::{IgnoredAny, MapAccess, Visitor},
|
||||
};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read, Write},
|
||||
};
|
||||
|
||||
pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_CONTAINERS: usize = 512;
|
||||
// This switch stays false until Windows/Linux debug/release codec + ordinary
|
||||
// lifecycle gates pass. Tests exercise the candidate through the same private
|
||||
// entry point with depth_128=true. It is not a user-controlled parser option.
|
||||
pub(super) const DEPTH_128_VALIDATED: bool = false;
|
||||
|
||||
/// Allocation cardinalities, independent of graph admission and numeric work.
|
||||
/// The default deliberately admits the existing CLI's 100000 graph objects,
|
||||
/// 250000 records and 500000 ordered references within its 64 MiB envelope.
|
||||
/// Strings have no new 1 MiB restriction: any span fitting that envelope fits.
|
||||
/// These are explicit slot/text bounds, not an assertion of process RSS.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Profile {
|
||||
pub bytes: usize,
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub container_entries: usize,
|
||||
pub string_bytes: usize,
|
||||
pub total_string_bytes: usize,
|
||||
pub expression_nodes: usize,
|
||||
}
|
||||
impl Profile {
|
||||
pub const DOCUMENT: Self = Self {
|
||||
bytes: MAX_DOCUMENT_BYTES,
|
||||
tokens: 16 * 1024 * 1024,
|
||||
entries: 8 * 1024 * 1024,
|
||||
container_entries: 500_000,
|
||||
string_bytes: MAX_DOCUMENT_BYTES,
|
||||
total_string_bytes: MAX_DOCUMENT_BYTES,
|
||||
expression_nodes: expression_serde::MAX_JSON_EXPRESSION_NODES,
|
||||
};
|
||||
pub fn with_byte_limit(maximum: usize) -> Self {
|
||||
Self {
|
||||
bytes: maximum.min(MAX_DOCUMENT_BYTES),
|
||||
..Self::DOCUMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Usage {
|
||||
pub tokens: usize,
|
||||
pub entries: usize,
|
||||
pub string_bytes: usize,
|
||||
pub deepest_container: usize,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Container {
|
||||
delimiter: u8,
|
||||
entries: usize,
|
||||
first: bool,
|
||||
}
|
||||
const EMPTY: Container = Container {
|
||||
delimiter: 0,
|
||||
entries: 0,
|
||||
first: true,
|
||||
};
|
||||
|
||||
fn limit(message: &str) -> FileError {
|
||||
FileError::Limit(message.into())
|
||||
}
|
||||
fn charge(used: &mut usize, amount: usize, maximum: usize, message: &str) -> Result<(), FileError> {
|
||||
if amount > maximum.saturating_sub(*used) {
|
||||
return Err(limit(message));
|
||||
}
|
||||
*used += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn preflight(bytes: &[u8], profile: Profile) -> Result<Usage, FileError> {
|
||||
if bytes.len() > profile.bytes {
|
||||
return Err(limit("JSON input byte limit exceeded."));
|
||||
}
|
||||
// Fixed storage: no Vec/Value/String and no recursive calls during preflight.
|
||||
let mut containers = [EMPTY; MAX_CONTAINERS];
|
||||
let mut depth = 0usize;
|
||||
let mut usage = Usage::default();
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let byte = bytes[cursor];
|
||||
if byte.is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
charge(
|
||||
&mut usage.tokens,
|
||||
1,
|
||||
profile.tokens,
|
||||
"JSON token limit exceeded.",
|
||||
)?;
|
||||
if !matches!(byte, b',' | b':' | b'}' | b']') && depth > 0 {
|
||||
let frame = &mut containers[depth - 1];
|
||||
if frame.first {
|
||||
charge(
|
||||
&mut frame.entries,
|
||||
1,
|
||||
profile.container_entries,
|
||||
"JSON container entry limit exceeded.",
|
||||
)?;
|
||||
charge(
|
||||
&mut usage.entries,
|
||||
1,
|
||||
profile.entries,
|
||||
"JSON aggregate entry limit exceeded.",
|
||||
)?;
|
||||
frame.first = false;
|
||||
}
|
||||
}
|
||||
match byte {
|
||||
b'{' | b'[' => {
|
||||
if depth == MAX_CONTAINERS {
|
||||
return Err(limit("JSON container depth exceeds 512."));
|
||||
}
|
||||
containers[depth] = Container {
|
||||
delimiter: byte,
|
||||
..EMPTY
|
||||
};
|
||||
depth += 1;
|
||||
usage.deepest_container = usage.deepest_container.max(depth);
|
||||
cursor += 1;
|
||||
}
|
||||
b'}' | b']' => {
|
||||
if depth == 0
|
||||
|| !matches!(
|
||||
(containers[depth - 1].delimiter, byte),
|
||||
(b'{', b'}') | (b'[', b']')
|
||||
)
|
||||
{
|
||||
return Err(FileError::Invalid(
|
||||
"Mismatched JSON container delimiter.".into(),
|
||||
));
|
||||
}
|
||||
depth -= 1;
|
||||
cursor += 1;
|
||||
}
|
||||
b',' => {
|
||||
if depth > 0 {
|
||||
containers[depth - 1].first = true;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
b':' => cursor += 1,
|
||||
b'"' => {
|
||||
cursor += 1;
|
||||
let start = cursor;
|
||||
let mut closed = false;
|
||||
while cursor < bytes.len() {
|
||||
match bytes[cursor] {
|
||||
b'"' => {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
b'\\' => {
|
||||
// Skip exactly one escaped byte, including an escaped
|
||||
// backslash. Serde validates actual escapes/UTF-8.
|
||||
cursor += 1;
|
||||
if cursor < bytes.len() {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
_ => cursor += 1,
|
||||
}
|
||||
}
|
||||
let span = cursor - start;
|
||||
if span > profile.string_bytes {
|
||||
return Err(limit("JSON string byte limit exceeded."));
|
||||
}
|
||||
charge(
|
||||
&mut usage.string_bytes,
|
||||
span,
|
||||
profile.total_string_bytes,
|
||||
"JSON aggregate string byte limit exceeded.",
|
||||
)?;
|
||||
if !closed {
|
||||
return Err(FileError::Invalid("Unterminated JSON string.".into()));
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {
|
||||
// Count a scalar token without interpreting its value. Invalid
|
||||
// tokens and invalid whitespace must still fail the Serde pass.
|
||||
cursor += 1;
|
||||
while cursor < bytes.len()
|
||||
&& !bytes[cursor].is_ascii_whitespace()
|
||||
&& !matches!(
|
||||
bytes[cursor],
|
||||
b'{' | b'[' | b'}' | b']' | b',' | b':' | b'"'
|
||||
)
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
return Err(FileError::Invalid("Unclosed JSON container.".into()));
|
||||
}
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
struct Version(Option<u64>);
|
||||
impl<'de> Deserialize<'de> for Version {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Header;
|
||||
impl<'de> Visitor<'de> for Header {
|
||||
type Value = Version;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a project header")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Version, A::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(field_identifier)]
|
||||
enum Key {
|
||||
#[serde(rename = "schema_version")]
|
||||
Schema,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
let mut version = None;
|
||||
while let Some(key) = access.next_key::<Key>()? {
|
||||
match key {
|
||||
Key::Schema => {
|
||||
if version.is_some() {
|
||||
return Err(serde::de::Error::duplicate_field("schema_version"));
|
||||
}
|
||||
version = Some(access.next_value()?);
|
||||
}
|
||||
Key::Other => {
|
||||
access.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Version(version))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Header)
|
||||
}
|
||||
}
|
||||
|
||||
/// IgnoredAny in serde_json walks arbitrary ignored containers iteratively.
|
||||
/// This probe does not materialize ignored subtrees. Serde's scratch buffer for
|
||||
/// container markers/escaped keys is bounded by the preceding raw scan. It
|
||||
/// consumes the entire original stream and rejects duplicate/escaped versions.
|
||||
pub(super) fn version(bytes: &[u8]) -> Result<Option<u64>, FileError> {
|
||||
document_stack(|| {
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
let version = Version::deserialize(&mut deserializer)?;
|
||||
deserializer.end()?;
|
||||
Ok(version.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn document_stack<R>(operation: impl FnOnce() -> R) -> R {
|
||||
// Non-Expr Project and historical schemas are finitely nested by their
|
||||
// concrete Rust types. Reserve for that outer generated Serde frame chain;
|
||||
// only recursive Expr edges use the smaller repeated boundary guard.
|
||||
stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, operation)
|
||||
}
|
||||
pub(super) fn current(
|
||||
bytes: &[u8],
|
||||
profile: Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Project, FileError> {
|
||||
preflight(bytes, profile)?;
|
||||
let _scope = Scope::decoding(profile.expression_nodes);
|
||||
document_stack(|| {
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
if depth_128 {
|
||||
deserializer.disable_recursion_limit();
|
||||
}
|
||||
let project = Project::deserialize(&mut deserializer)?;
|
||||
deserializer.end()?;
|
||||
Ok(project)
|
||||
})
|
||||
}
|
||||
pub(super) fn legacy<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, FileError> {
|
||||
// Never disable the legacy recursion guard, including its temporary Value.
|
||||
document_stack(|| Ok(serde_json::from_slice(bytes)?))
|
||||
}
|
||||
|
||||
pub(super) fn discard_value(value: serde_json::Value) {
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
serde_json::Value::Array(values) => pending.extend(values),
|
||||
serde_json::Value::Object(values) => pending.extend(values.into_values()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_bounded(bytes: &mut Vec<u8>, additional: usize, maximum: usize) -> io::Result<()> {
|
||||
let required = bytes.len() + additional;
|
||||
if required > bytes.capacity() {
|
||||
let next = bytes
|
||||
.capacity()
|
||||
.max(4096)
|
||||
.saturating_mul(2)
|
||||
.max(required)
|
||||
.min(maximum);
|
||||
bytes
|
||||
.try_reserve_exact(next - bytes.len())
|
||||
.map_err(io::Error::other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CappedWriter {
|
||||
bytes: Vec<u8>,
|
||||
maximum: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
impl CappedWriter {
|
||||
fn new(maximum: usize) -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
maximum,
|
||||
exceeded: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Write for CappedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.len() > self.maximum.saturating_sub(self.bytes.len()) {
|
||||
self.exceeded = true;
|
||||
return Err(io::Error::other("JSON output byte limit exceeded."));
|
||||
}
|
||||
// Check before reserve/extend; failed output never creates a giant Vec.
|
||||
reserve_bounded(&mut self.bytes, bytes.len(), self.maximum)?;
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact output avoids indentation multiplying the cost of legal depth128.
|
||||
/// The returned bytes have already passed the identical reader resource profile.
|
||||
pub(super) fn encode(
|
||||
project: &Project,
|
||||
profile: Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
let _scope = Scope::encoding(profile.expression_nodes);
|
||||
let mut writer = CappedWriter::new(profile.bytes);
|
||||
let result = document_stack(|| serde_json::to_writer(&mut writer, project));
|
||||
if writer.exceeded {
|
||||
return Err(limit("JSON output byte limit exceeded."));
|
||||
}
|
||||
result?;
|
||||
writer.write_all(b"\n").map_err(|error| {
|
||||
if writer.exceeded {
|
||||
limit("JSON output byte limit exceeded.")
|
||||
} else {
|
||||
FileError::Io(error)
|
||||
}
|
||||
})?;
|
||||
// Establish save/reopen symmetry before filesystem mutation, including the
|
||||
// conservative production guard until the deeper profile is validated.
|
||||
let reopened = current(&writer.bytes, profile, depth_128)?;
|
||||
if reopened != *project {
|
||||
return Err(FileError::Invalid(
|
||||
"JSON serialization did not preserve the project value.".into(),
|
||||
));
|
||||
}
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
pub(super) fn read_bounded(path: &std::path::Path, maximum: usize) -> Result<Vec<u8>, FileError> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
if file.metadata()?.len() > maximum as u64 {
|
||||
return Err(limit("Input file byte limit exceeded."));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let count = file.read(&mut chunk)?;
|
||||
if count == 0 {
|
||||
return Ok(bytes);
|
||||
}
|
||||
if count > maximum.saturating_sub(bytes.len()) {
|
||||
return Err(limit("Input file byte limit exceeded."));
|
||||
}
|
||||
reserve_bounded(&mut bytes, count, maximum)?;
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matches_file(path: &std::path::Path, expected: &[u8]) -> io::Result<bool> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
if file.metadata()?.len() != expected.len() as u64 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut compared = 0usize;
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let count = file.read(&mut chunk)?;
|
||||
if count == 0 {
|
||||
return Ok(compared == expected.len());
|
||||
}
|
||||
if expected.get(compared..compared + count) != Some(&chunk[..count]) {
|
||||
return Ok(false);
|
||||
}
|
||||
compared += count;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,612 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, ElementBody, Id, OaBody,
|
||||
analysis::{
|
||||
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, Expr, Parameter,
|
||||
ParameterId, ScalarType, UnaryOp,
|
||||
},
|
||||
language::ParseLimits,
|
||||
persistence,
|
||||
};
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const IDENTITY: &str = "00000000-0000-0000-0000-000000000001";
|
||||
const PARAMETER: &str = "00000000-0000-0000-0000-000000000002";
|
||||
const MAIN: &str = "00000000-0000-0000-0000-000000000003";
|
||||
fn id(value: &str) -> Id {
|
||||
Id::parse_str(value).unwrap()
|
||||
}
|
||||
fn boolean_type() -> AnalysisType {
|
||||
AnalysisType::Scalar(ScalarType::Boolean)
|
||||
}
|
||||
fn definition(id: Id, alias: &str, parameters: Vec<Parameter>, body: Expr) -> Definition {
|
||||
Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters,
|
||||
result: boolean_type(),
|
||||
body: Binding::Expression(body),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn project() -> Project {
|
||||
let mut project = Project::empty();
|
||||
project.id = Id::from_u128(1000);
|
||||
project.definitions.insert(
|
||||
id(IDENTITY),
|
||||
definition(
|
||||
id(IDENTITY),
|
||||
"identity",
|
||||
vec![Parameter {
|
||||
id: ParameterId(id(PARAMETER)),
|
||||
name: "value".into(),
|
||||
value_type: boolean_type(),
|
||||
}],
|
||||
Expr::Parameter(ParameterId(id(PARAMETER))),
|
||||
),
|
||||
);
|
||||
project.definitions.insert(
|
||||
id(MAIN),
|
||||
definition(id(MAIN), "main", vec![], Expr::Boolean(true)),
|
||||
);
|
||||
project
|
||||
}
|
||||
fn wire(expression: &str) -> Vec<u8> {
|
||||
let shallow = serde_json::to_string(&project()).unwrap();
|
||||
assert_eq!(shallow.matches(r#"{"Boolean":true}"#).count(), 1);
|
||||
shallow
|
||||
.replace(r#"{"Boolean":true}"#, expression)
|
||||
.into_bytes()
|
||||
}
|
||||
fn chain(depth: usize, call: bool) -> String {
|
||||
assert!(depth > 0);
|
||||
let prefix = if call {
|
||||
format!(r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":"#)
|
||||
} else {
|
||||
r#"{"Unary":{"op":"Not","operand":"#.into()
|
||||
};
|
||||
let suffix = if call { "}}}" } else { "}}" };
|
||||
format!(
|
||||
"{}{}{}",
|
||||
prefix.repeat(depth - 1),
|
||||
r#"{"Boolean":true}"#,
|
||||
suffix.repeat(depth - 1)
|
||||
)
|
||||
}
|
||||
fn candidate(bytes: &[u8], profile: Profile) -> Result<Project, FileError> {
|
||||
persistence::decode_with_report_profile(bytes, ParseLimits::UNLIMITED, profile, true)
|
||||
.map(|decoded| decoded.project)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_preflight_has_exact_lowerable_boundaries() {
|
||||
let bytes = br#"{"key":["a\\\"[{}]",0,true],"other":"\u0061"}"#;
|
||||
let usage = preflight(bytes, Profile::DOCUMENT).unwrap();
|
||||
let exact = Profile {
|
||||
bytes: bytes.len(),
|
||||
tokens: usage.tokens,
|
||||
entries: usage.entries,
|
||||
container_entries: 3,
|
||||
string_bytes: 9,
|
||||
total_string_bytes: usage.string_bytes,
|
||||
..Profile::DOCUMENT
|
||||
};
|
||||
preflight(bytes, exact).unwrap();
|
||||
for lower in [
|
||||
Profile {
|
||||
bytes: exact.bytes - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
tokens: exact.tokens - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
entries: exact.entries - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
container_entries: 2,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
total_string_bytes: exact.total_string_bytes - 1,
|
||||
..exact
|
||||
},
|
||||
Profile {
|
||||
string_bytes: exact.string_bytes - 1,
|
||||
..exact
|
||||
},
|
||||
] {
|
||||
assert!(matches!(preflight(bytes, lower), Err(FileError::Limit(_))));
|
||||
}
|
||||
let deepest = format!("{}0{}", "[".repeat(512), "]".repeat(512));
|
||||
assert_eq!(
|
||||
preflight(deepest.as_bytes(), Profile::DOCUMENT)
|
||||
.unwrap()
|
||||
.deepest_container,
|
||||
512
|
||||
);
|
||||
assert!(matches!(
|
||||
preflight(format!("[{deepest}]").as_bytes(), Profile::DOCUMENT),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
// A huge ignored scalar array exhausts preflight before a header is decoded.
|
||||
assert!(matches!(
|
||||
preflight(
|
||||
br#"{"schema_version":8,"junk":[0,0,0,0]}"#,
|
||||
Profile {
|
||||
container_entries: 3,
|
||||
..Profile::DOCUMENT
|
||||
}
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_is_not_a_parallel_json_grammar() {
|
||||
for bytes in [
|
||||
br#"{"schema_version":8}{}"#.as_slice(),
|
||||
br#"{"schema_version":8,}"#,
|
||||
br#"{"schema_version":8 "junk":0}"#,
|
||||
br#"{"schema_version":8,"schema_version":8}"#,
|
||||
br#"{"schema_version":8,"\u0073chema_version":8}"#,
|
||||
br#"{"schema_version":8,"junk":"\q"}"#,
|
||||
br#"{"schema_version":8,"junk":"unfinished}"#,
|
||||
br#"{"schema_version":8,"junk":[}"#,
|
||||
] {
|
||||
assert!(candidate(bytes, Profile::DOCUMENT).is_err(), "{bytes:?}");
|
||||
}
|
||||
let mut invalid_utf8 = wire(r#"{"Boolean":true}"#);
|
||||
let offset = invalid_utf8
|
||||
.windows(7)
|
||||
.position(|window| window == b"mission")
|
||||
.unwrap();
|
||||
invalid_utf8[offset] = 0xff;
|
||||
assert!(candidate(&invalid_utf8, Profile::DOCUMENT).is_err());
|
||||
let escaped = String::from_utf8(wire(r#"{"Boolean":true}"#))
|
||||
.unwrap()
|
||||
.replace(r#""schema_version""#, r#""\u0073chema_version""#);
|
||||
assert_eq!(
|
||||
candidate(escaped.as_bytes(), Profile::DOCUMENT).unwrap(),
|
||||
project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_serde_keeps_each_existing_expression_variant() {
|
||||
let n = r#"{"Number":{"numerator":"1","denominator":"1"}}"#;
|
||||
let b = r#"{"Boolean":true}"#;
|
||||
let expressions = [
|
||||
b.to_string(),
|
||||
n.to_string(),
|
||||
format!(
|
||||
r#"{{"Quantity":{{"magnitude":{{"numerator":"1","denominator":"1"}},"unit":{{"Local":"{IDENTITY}"}}}}}}"#
|
||||
),
|
||||
format!(r#"{{"Property":"{IDENTITY}"}}"#),
|
||||
format!(r#"{{"Parameter":"{PARAMETER}"}}"#),
|
||||
format!(r#"{{"List":[{n},{n}]}}"#),
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{b}}}}}"#),
|
||||
format!(r#"{{"Binary":{{"op":"Add","left":{n},"right":{n}}}}}"#),
|
||||
format!(r#"{{"If":{{"condition":{b},"when_true":{n},"when_false":{n}}}}}"#),
|
||||
format!(
|
||||
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{b}}}}}}}"#
|
||||
),
|
||||
format!(r#"{{"Sum":{{"values":{{"List":[{n}]}},"initial":{n}}}}}"#),
|
||||
format!(r#"{{"Power":{{"base":{n},"exponent":2}}}}"#),
|
||||
format!(r#"{{"AsKind":{{"kind":{{"Local":"{IDENTITY}"}},"value":{n}}}}}"#),
|
||||
];
|
||||
for original in expressions {
|
||||
let value: Expr = serde_json::from_str(&original).unwrap();
|
||||
let copy = value.clone();
|
||||
assert!(value == copy);
|
||||
assert_eq!(serde_json::to_string(&value).unwrap(), original);
|
||||
assert_eq!(format!("{value:?}"), format!("{copy:?}"));
|
||||
}
|
||||
let list = Expr::List(vec![Expr::Boolean(true), Expr::Boolean(false)]);
|
||||
assert_eq!(format!("{list:?}"), "List([Boolean(true), Boolean(false)])");
|
||||
assert!(list != Expr::List(vec![Expr::Boolean(false), Expr::Boolean(true)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_profile_does_not_contract_declared_cli_collection_counts() {
|
||||
// Exercise original-byte typed construction at the existing graph ceiling.
|
||||
// This gate checks codec/count compatibility, not 100000-object UI speed.
|
||||
let mut model = Project::empty();
|
||||
model.id = Id::from_u128(1_000_000);
|
||||
for value in 1..=100_000 {
|
||||
let id = Id::from_u128(value);
|
||||
model.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("activity_{value}"),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body: ElementBody::Oa(OaBody::Activity),
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
let decoded = current(&bytes, Profile::DOCUMENT, true).unwrap();
|
||||
assert_eq!(decoded.elements.len(), 100_000);
|
||||
let cli = ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
};
|
||||
cli.check_model(&decoded).unwrap();
|
||||
let references = format!(
|
||||
"[{}\"{IDENTITY}\"]",
|
||||
format!("\"{IDENTITY}\",").repeat(499_999)
|
||||
);
|
||||
preflight(references.as_bytes(), Profile::DOCUMENT).unwrap();
|
||||
assert!(matches!(
|
||||
preflight(
|
||||
format!("[\"{IDENTITY}\",{}]", &references[1..references.len() - 1]).as_bytes(),
|
||||
Profile::DOCUMENT
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
// Metadata over the initially proposed 1 MiB limit remains admissible.
|
||||
let mut text = Project::empty();
|
||||
text.description = "x".repeat(1024 * 1024 + 1);
|
||||
let bytes = encode(&text, Profile::DOCUMENT, true).unwrap();
|
||||
assert_eq!(current(&bytes, Profile::DOCUMENT, true).unwrap(), text);
|
||||
eprintln!(
|
||||
"JSON profile: graph bytes={}, ordered-reference bytes={}, max container entries={}",
|
||||
serde_json::to_string(&model).unwrap().len(),
|
||||
references.len(),
|
||||
Profile::DOCUMENT.container_entries
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_save_always_reopens_with_its_reader_profile() {
|
||||
let deep = candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.json");
|
||||
std::fs::write(&path, b"previous").unwrap();
|
||||
let saved = persistence::save_checked(&path, &deep, Some(b"previous"));
|
||||
if DEPTH_128_VALIDATED {
|
||||
let bytes = saved.unwrap();
|
||||
assert_eq!(persistence::decode(&bytes).unwrap(), deep);
|
||||
} else {
|
||||
assert!(saved.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous");
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_phase() {
|
||||
for call in [false, true] {
|
||||
let bytes = wire(&chain(128, call));
|
||||
let usage = preflight(&bytes, Profile::DOCUMENT).unwrap();
|
||||
let model = candidate(&bytes, Profile::DOCUMENT).unwrap();
|
||||
let copy = model.clone();
|
||||
assert!(model == copy);
|
||||
let debug = format!("{model:?}");
|
||||
assert!(!debug.is_empty());
|
||||
let output = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
assert!(candidate(&output, Profile::DOCUMENT).unwrap() == model);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("deep.json");
|
||||
let saved = persistence::save_checked_profile(&path, &model, None, Profile::DOCUMENT, true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
candidate(
|
||||
&read_bounded(&path, MAX_DOCUMENT_BYTES).unwrap(),
|
||||
Profile::DOCUMENT
|
||||
)
|
||||
.unwrap()
|
||||
== model
|
||||
);
|
||||
assert_eq!(saved, output);
|
||||
eprintln!(
|
||||
"accepted call={call} depth128 input={} output={} tokens={} entries={} raw strings={} containers={} debug={}",
|
||||
bytes.len(),
|
||||
output.len(),
|
||||
usage.tokens,
|
||||
usage.entries,
|
||||
usage.string_bytes,
|
||||
usage.deepest_container,
|
||||
debug.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
fn refused_phase() {
|
||||
for call in [false, true] {
|
||||
let error = candidate(&wire(&chain(129, call)), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(error.to_string().contains("logical depth"), "{error}");
|
||||
}
|
||||
let wide = format!(
|
||||
r#"{{"List":[{}null]}}"#,
|
||||
r#"{"Boolean":true},"#.repeat(1024)
|
||||
);
|
||||
let error = candidate(&wire(&wide), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(error.to_string().contains("1024"), "{error}");
|
||||
let bytes = wire(&chain(128, true));
|
||||
let exact_model = candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
expression_nodes: 129,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let error = candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
expression_nodes: 128,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
// Identity's Parameter plus main's 128-node expression exceed this total.
|
||||
assert!(error.to_string().contains("allocation count"), "{error}");
|
||||
let scan = preflight(&bytes, Profile::DOCUMENT).unwrap();
|
||||
assert!(matches!(
|
||||
candidate(
|
||||
&bytes,
|
||||
Profile {
|
||||
tokens: scan.tokens - 1,
|
||||
..Profile::DOCUMENT
|
||||
}
|
||||
),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
document_stack(|| {
|
||||
use crate::analysis::{AdmissionLimits, admit};
|
||||
let events = admit(&exact_model, AdmissionLimits::REFERENCE)
|
||||
.unwrap()
|
||||
.admission_usage()
|
||||
.static_events;
|
||||
admit(
|
||||
&exact_model,
|
||||
AdmissionLimits {
|
||||
max_static_events: events,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let error = match admit(
|
||||
&exact_model,
|
||||
AdmissionLimits {
|
||||
max_static_events: events - 1,
|
||||
},
|
||||
) {
|
||||
Ok(_) => panic!("Static work below the measured exact count must refuse."),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code, "analysis_limit");
|
||||
});
|
||||
let mut tree = r#"{"Boolean":true}"#.to_owned();
|
||||
for _ in 0..15 {
|
||||
tree = format!(r#"{{"Binary":{{"op":"And","left":{tree},"right":{tree}}}}}"#);
|
||||
}
|
||||
// 65535 balanced nodes plus one unary node is the stored-tree boundary.
|
||||
tree = format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#);
|
||||
let at_tree_limit = wire(&tree);
|
||||
current(&at_tree_limit, Profile::DOCUMENT, true).unwrap();
|
||||
let beyond = wire(&format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#));
|
||||
assert!(
|
||||
current(&beyond, Profile::DOCUMENT, true)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("65536")
|
||||
);
|
||||
// No downstream work after refusal poisons the next ordinary operation.
|
||||
candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
|
||||
}
|
||||
fn malformed_phase() {
|
||||
let deep = chain(127, false);
|
||||
for (label, expression) in [
|
||||
(
|
||||
"duplicate operand",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"operand":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"escaped duplicate",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"\u006fperand":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"unknown field",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"surprise":null}}}}"#),
|
||||
),
|
||||
(
|
||||
"duplicate enum",
|
||||
format!(r#"{{"Unary":{{"op":"Not","operand":{deep}}},"Boolean":true}}"#),
|
||||
),
|
||||
(
|
||||
"duplicate parameter",
|
||||
format!(
|
||||
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{deep},"\u0030{}":null}}}}}}"#,
|
||||
&PARAMETER[1..]
|
||||
),
|
||||
),
|
||||
] {
|
||||
let error = candidate(&wire(&expression), Profile::DOCUMENT).unwrap_err();
|
||||
assert!(
|
||||
!error.to_string().contains("logical depth"),
|
||||
"{label}: {error}"
|
||||
);
|
||||
eprintln!("{label}: {error}");
|
||||
}
|
||||
let complete = String::from_utf8(wire(&chain(128, true))).unwrap();
|
||||
let duplicate_late = format!(
|
||||
r#"{},"name":"late duplicate"}}"#,
|
||||
&complete[..complete.len() - 1]
|
||||
);
|
||||
assert!(candidate(duplicate_late.as_bytes(), Profile::DOCUMENT).is_err());
|
||||
assert!(candidate(format!("{complete} null").as_bytes(), Profile::DOCUMENT).is_err());
|
||||
let invalid_surrogate = complete.replace(r#""description":"""#, r#""description":"\uD800""#);
|
||||
assert!(candidate(invalid_surrogate.as_bytes(), Profile::DOCUMENT).is_err());
|
||||
// Fully rejected raw depth also tears down without a recursive JSON Value.
|
||||
let excessive = format!("{}0{}", "[".repeat(100_000), "]".repeat(100_000));
|
||||
assert!(matches!(
|
||||
preflight(excessive.as_bytes(), Profile::DOCUMENT),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
}
|
||||
fn ownership_phase() {
|
||||
let mut value = Expr::Boolean(true);
|
||||
for _ in 1..100_000 {
|
||||
value = Expr::Unary {
|
||||
op: UnaryOp::Not,
|
||||
operand: Box::new(value),
|
||||
};
|
||||
}
|
||||
let mut copy = value.clone();
|
||||
assert!(value == copy);
|
||||
let mut cursor = &mut copy;
|
||||
loop {
|
||||
match cursor {
|
||||
Expr::Unary { operand, .. } => cursor = operand,
|
||||
Expr::Boolean(leaf) => {
|
||||
*leaf = false;
|
||||
break;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert!(value != copy);
|
||||
// A streaming formatter avoids retaining a 100000-node Debug string.
|
||||
struct Counter(usize);
|
||||
impl fmt::Write for Counter {
|
||||
fn write_str(&mut self, value: &str) -> fmt::Result {
|
||||
self.0 += value.len();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut debug = Counter(0);
|
||||
write!(&mut debug, "{value:?}").unwrap();
|
||||
assert!(debug.0 > 100_000);
|
||||
assert!(serde_json::to_writer(io::sink(), &value).is_err());
|
||||
drop(value);
|
||||
drop(copy);
|
||||
eprintln!(
|
||||
"iterative manual100000 ownership/debug/drop completed; debug bytes={}",
|
||||
debug.0
|
||||
);
|
||||
}
|
||||
fn atomic_phase() {
|
||||
let model = project();
|
||||
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
|
||||
let exact = Profile {
|
||||
bytes: bytes.len(),
|
||||
..Profile::DOCUMENT
|
||||
};
|
||||
assert_eq!(encode(&model, exact, true).unwrap(), bytes);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("preserved.json");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let error = persistence::save_checked_profile(
|
||||
&path,
|
||||
&model,
|
||||
Some(b"original"),
|
||||
Profile {
|
||||
bytes: bytes.len() - 1,
|
||||
..Profile::DOCUMENT
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FileError::Limit(_)));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
let error = persistence::save_checked_profile(
|
||||
&path,
|
||||
&model,
|
||||
Some(b"different"),
|
||||
Profile::DOCUMENT,
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FileError::Conflict));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
assert!(read_bounded(&path, 7).is_err());
|
||||
assert_eq!(read_bounded(&path, 8).unwrap(), b"original");
|
||||
let mut writer = CappedWriter::new(3);
|
||||
writer.write_all(b"abc").unwrap();
|
||||
let capacity = writer.bytes.capacity();
|
||||
assert!(writer.write_all(b"d").is_err());
|
||||
assert_eq!(writer.bytes, b"abc");
|
||||
assert_eq!(writer.bytes.capacity(), capacity);
|
||||
}
|
||||
|
||||
/// Root runs this exact test in each approved target/profile. Each phase runs in
|
||||
/// another process so a stack overflow is an explicit failed phase, and inside
|
||||
/// a 128 KiB thread (smaller than the ordinary test stack). No RUST_MIN_STACK
|
||||
/// override, ignored test, release-only path or increased test stack.
|
||||
#[test]
|
||||
fn codec_lifecycle_subprocess() {
|
||||
const PHASE: &str = "ARCADIA_CODEC_LIFECYCLE_PHASE";
|
||||
if let Ok(phase) = std::env::var(PHASE) {
|
||||
eprintln!("codec child phase={phase}, configured thread stack=131072");
|
||||
std::thread::Builder::new()
|
||||
.name(format!("codec-{phase}"))
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(move || match phase.as_str() {
|
||||
"accepted" => accepted_phase(),
|
||||
"refused" => refused_phase(),
|
||||
"malformed" => malformed_phase(),
|
||||
"ownership" => ownership_phase(),
|
||||
"atomic" => atomic_phase(),
|
||||
_ => panic!("Unknown codec phase"),
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
for phase in ["accepted", "refused", "malformed", "ownership", "atomic"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"persistence::json_codec::tests::codec_lifecycle_subprocess",
|
||||
"--test-threads=1",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(PHASE, phase)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(120) {
|
||||
child.kill().unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
panic!(
|
||||
"codec {phase} timed out: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
eprintln!(
|
||||
"codec {phase}: status={} elapsed={:?}\n{}{}",
|
||||
output.status,
|
||||
start.elapsed(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"codec phase {phase} aborted or failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Containment while the complete source8 adapter remains unavailable.
|
||||
use super::*;
|
||||
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
|
||||
use crate::analysis_numeric::Dimension;
|
||||
use std::fs;
|
||||
|
||||
const ORDINARY_SOURCE7: &str = concat!(
|
||||
"arcadia 7;\n",
|
||||
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
|
||||
" description \"Retain café and exact identity\";\n",
|
||||
"}",
|
||||
);
|
||||
|
||||
fn ordinary() -> Project {
|
||||
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert!(decoded.migration.is_some());
|
||||
decoded.project
|
||||
}
|
||||
|
||||
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
|
||||
let mut names = fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
// A missing parent makes a mistakenly early NamedTempFile creation observable
|
||||
// as Io, even when the temp would otherwise be removed again on error.
|
||||
fn rejected_output(
|
||||
project: &Project,
|
||||
output: &[u8],
|
||||
profile: json_codec::Profile,
|
||||
check: impl Fn(&FileError),
|
||||
) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let existing = root.path().join("existing.arcadia");
|
||||
let sentinel = b"external bytes must survive\r\n";
|
||||
fs::write(&existing, sentinel).unwrap();
|
||||
for path in [
|
||||
existing.clone(),
|
||||
root.path().join("new.arcadia"),
|
||||
root.path().join("missing-parent/new.arcadia"),
|
||||
] {
|
||||
let before = directory_entries(root.path());
|
||||
let expected = (path == existing).then_some(sentinel.as_slice());
|
||||
let error =
|
||||
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
|
||||
output.to_vec()
|
||||
})
|
||||
.unwrap_err();
|
||||
check(&error);
|
||||
assert_eq!(directory_entries(root.path()), before);
|
||||
assert_eq!(fs::read(&existing).unwrap(), sentinel);
|
||||
if path != existing {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
b"arcadia",
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
|
||||
let mut project = ordinary();
|
||||
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
|
||||
project.definitions.insert(
|
||||
id,
|
||||
Definition {
|
||||
id,
|
||||
alias: "duration".into(),
|
||||
name: "Duration".into(),
|
||||
description: "Unused definitions are still authored model data.".into(),
|
||||
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
|
||||
algebra: QuantityAlgebra::Linear,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_project(&project).unwrap();
|
||||
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
|
||||
assert_eq!(decoded.id, project.id);
|
||||
assert_eq!(decoded.name, project.name);
|
||||
assert_eq!(decoded.elements, project.elements);
|
||||
assert!(decoded.definitions.is_empty());
|
||||
assert_ne!(decoded, project);
|
||||
rejected_output(
|
||||
&project,
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source does not reproduce the complete requested project."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
|
||||
rejected_output(
|
||||
&ordinary(),
|
||||
ORDINARY_SOURCE7.as_bytes(),
|
||||
json_codec::Profile::DOCUMENT,
|
||||
|error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
|
||||
let project = ordinary();
|
||||
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
|
||||
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
|
||||
assert!(matches!(error, FileError::Limit(_)), "{error}");
|
||||
});
|
||||
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
|
||||
terminated.push(b'\n');
|
||||
let profile = json_codec::Profile::with_byte_limit(terminated.len());
|
||||
// Existing final newline is not added twice: decoding now reaches the
|
||||
// independent migration refusal, rather than exceeding this exact limit.
|
||||
rejected_output(&project, &terminated, profile, |error| {
|
||||
assert!(
|
||||
matches!(error, FileError::Invalid(message)
|
||||
if message == "Generated source unexpectedly requires migration."),
|
||||
"{error}"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
use crate::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Architectural/project references, including analysis subjects/properties.
|
||||
/// Layer-neutral definition references and scoped parameters have separate domains.
|
||||
/// Every live reference in a typed element, including structural ownership.
|
||||
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||
if e.kind().is_analysis() {
|
||||
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
|
||||
references.extend(e.parent);
|
||||
return references;
|
||||
}
|
||||
let mut copy = e.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||
@@ -20,8 +14,6 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
/// Architectural mutable slots only. Analysis map keys require the fallible
|
||||
/// whole-project remap; use analysis::references for its typed domains.
|
||||
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
if let Some(parent) = &mut e.parent {
|
||||
visit(parent);
|
||||
@@ -211,10 +203,7 @@ 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) -> 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);
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -276,7 +265,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -417,9 +405,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
.iter()
|
||||
.map(|id| (*id, p.named(*id).to_string()))
|
||||
.collect();
|
||||
// Analysis dependencies remain live: deleting their target rejects the
|
||||
// transaction unless that transaction also explicitly repairs/removes them.
|
||||
p.definitions.retain(|id, _| !removed.contains(id));
|
||||
p.elements.retain(|id, _| !removed.contains(id));
|
||||
p.relations.retain(|id, _| !removed.contains(id));
|
||||
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||
|
||||
@@ -604,9 +604,6 @@ fn catalog_facets(
|
||||
include_linked: bool,
|
||||
) -> Result<Vec<FacetDrift>, String> {
|
||||
let (oa, sa) = (trace.oa, trace.sa);
|
||||
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
|
||||
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
|
||||
}
|
||||
let mut selectors: BTreeSet<_> = scalar_selectors()
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
|
||||
@@ -19,21 +19,6 @@ 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),
|
||||
@@ -468,47 +453,6 @@ fn apply_command(
|
||||
}
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateDefinition(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.definitions.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditDefinition(old, value) => {
|
||||
if !p.definitions.contains_key(old) { return Err(missing()); }
|
||||
let mut definition = value.clone();
|
||||
definition.id = *old;
|
||||
p.definitions.insert(*old, definition);
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetAnalysisBase { property, binding } => {
|
||||
let element = p.elements.get_mut(property).ok_or_else(missing)?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
|
||||
};
|
||||
*base = binding.clone();
|
||||
Some(*property)
|
||||
}
|
||||
Command::SetAnalysisOverride { case, property, binding } => {
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.insert(*property, binding.clone());
|
||||
Some(*case)
|
||||
}
|
||||
Command::ClearAnalysisOverride { case, property } => {
|
||||
let target = p.elements.get(property).ok_or_else(missing)?;
|
||||
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
|
||||
let layer = target.layer();
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.remove(property);
|
||||
Some(*case)
|
||||
}
|
||||
Command::CreateRelation(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.relations.insert(value.id, value.clone());
|
||||
@@ -891,10 +835,6 @@ fn edit_header(
|
||||
assign(&mut p.name, &mut p.description, &mut p.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.definitions.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.elements.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
@@ -938,13 +878,8 @@ 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);
|
||||
@@ -969,7 +904,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,7 +8,6 @@ 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;
|
||||
@@ -305,7 +304,71 @@ fn compile_once(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<(CompiledSourceProject, Edits), Vec<SourceDiagnostic>> {
|
||||
let mut traversal = traversal::Traversal::new(bundle, limits)?;
|
||||
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 declarations = Vec::new();
|
||||
let mut declaration_paths = Vec::new();
|
||||
let mut aliases = BTreeMap::new();
|
||||
@@ -318,8 +381,51 @@ fn compile_once(
|
||||
let mut includes = Vec::new();
|
||||
let mut insertions: Edits = BTreeMap::new();
|
||||
let mut graph_count = 0usize;
|
||||
while let Some(path) = traversal.next()? {
|
||||
let source = &bundle.files[&path];
|
||||
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."),
|
||||
)]);
|
||||
};
|
||||
let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err(
|
||||
|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
@@ -407,12 +513,46 @@ fn compile_once(
|
||||
declarations.push(declaration);
|
||||
declaration_paths.push(path.clone());
|
||||
}
|
||||
let mut children = Vec::new();
|
||||
for (raw, span) in unit.includes {
|
||||
includes.push(traversal.include(&path, &raw, span)?);
|
||||
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),
|
||||
));
|
||||
}
|
||||
traversal.finish_file(path);
|
||||
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()?;
|
||||
let header = project_header.expect("validated entry header");
|
||||
let index = SourceIndex {
|
||||
declarations: declaration_locations,
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
//! 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,13 +449,6 @@ pub fn plan_transition(
|
||||
request: &TransitionRequest,
|
||||
) -> Result<TransitionPlan, ModelError> {
|
||||
validate_candidate(project)?;
|
||||
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
|
||||
if request.sources.iter().any(is_analysis)
|
||||
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|
||||
|| mapping.target.as_ref().is_some_and(is_analysis)
|
||||
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
|
||||
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
|
||||
}
|
||||
let mut plan = TransitionPlan {
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
request: request.clone(),
|
||||
@@ -615,9 +608,6 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected.iter().any(is_analysis) {
|
||||
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
|
||||
}
|
||||
selected.retain(|id| {
|
||||
if omitted.contains(id) {
|
||||
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
|
||||
|
||||
@@ -180,100 +180,6 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
}
|
||||
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
validate_with_usage(p).0
|
||||
}
|
||||
|
||||
/// Runs the shared static/architectural route once and retains successful static
|
||||
/// metadata without retaining a Project-borrowing environment. Architectural
|
||||
/// issue production is outside the static meter and any caller auxiliary ledger;
|
||||
/// callers must reserve separately before mapping or retaining these issues.
|
||||
pub(crate) fn validate_with_usage(
|
||||
p: &Project,
|
||||
) -> (
|
||||
Vec<Issue>,
|
||||
Option<(
|
||||
crate::analysis::AdmissionLimits,
|
||||
crate::analysis::AdmissionUsage,
|
||||
)>,
|
||||
) {
|
||||
// Bound analysis trees before architectural visitors can clone an element.
|
||||
// Admission itself never enters this public validator recursively.
|
||||
let environment = match crate::analysis::admit_structure_checked(
|
||||
p,
|
||||
crate::analysis::AdmissionLimits::REFERENCE,
|
||||
) {
|
||||
Ok(environment) => environment,
|
||||
Err(failure) => {
|
||||
let mut out = Vec::new();
|
||||
error(
|
||||
&mut out,
|
||||
failure.object,
|
||||
&failure.code,
|
||||
&failure.field,
|
||||
failure.message,
|
||||
);
|
||||
return (out, None);
|
||||
}
|
||||
};
|
||||
let metadata = (
|
||||
environment.admission_limits(),
|
||||
environment.admission_usage(),
|
||||
);
|
||||
drop(environment);
|
||||
(validate_structure(p), Some(metadata))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_admission_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_static_metadata_survives_architectural_warnings() {
|
||||
let mut project = Project::empty();
|
||||
project.name.clear();
|
||||
let (issues, metadata) = validate_with_usage(&project);
|
||||
let (limits, usage) = metadata.expect("static admission succeeds");
|
||||
assert_eq!(limits, crate::analysis::AdmissionLimits::REFERENCE);
|
||||
assert!(usage.static_events > 0);
|
||||
assert_eq!(usage.stored_expression_nodes, 0);
|
||||
assert!(issues.iter().any(|issue| issue.code == "unnamed"
|
||||
&& issue.element == Some(project.id)
|
||||
&& issue.severity == Severity::Warning));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_failure_returns_no_metadata_and_skips_architectural_warnings() {
|
||||
use crate::analysis::{
|
||||
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, ScalarType,
|
||||
};
|
||||
let mut project = Project::empty();
|
||||
project.name.clear();
|
||||
let key = Id::from_u128(10);
|
||||
project.definitions.insert(
|
||||
key,
|
||||
Definition {
|
||||
id: Id::from_u128(11),
|
||||
alias: "f".into(),
|
||||
name: "F".into(),
|
||||
description: String::new(),
|
||||
body: DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters: Vec::new(),
|
||||
result: AnalysisType::Scalar(ScalarType::Number),
|
||||
body: Binding::Unbound,
|
||||
}),
|
||||
},
|
||||
);
|
||||
let (issues, metadata) = validate_with_usage(&project);
|
||||
assert!(metadata.is_none());
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].severity, Severity::Error);
|
||||
assert_eq!(issues[0].element, Some(key));
|
||||
assert_eq!(issues[0].code, "identity_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).unwrap();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
+249
-36
@@ -7,6 +7,8 @@ mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
@@ -415,9 +417,17 @@ pub enum Message {
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
SourceViewport,
|
||||
SourceViewport(Option<Id>),
|
||||
SourceSymbols(
|
||||
crate::source_navigation::SymbolContext,
|
||||
crate::source_navigation::SymbolEvent,
|
||||
),
|
||||
SourceSymbolsReady(crate::source_navigation::Completion),
|
||||
SourceRename(source_rename::Event),
|
||||
SourceRenameReady(source_rename::Completion),
|
||||
SourceForSelection,
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
SourceFindClose(crate::source_navigation::SymbolContext, Id),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
@@ -491,6 +501,9 @@ 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 source_rename: source_rename::State,
|
||||
pub model_issues: Vec<validation::Issue>,
|
||||
pub migration: Option<persistence::MigrationReport>,
|
||||
source_version: u64,
|
||||
@@ -596,6 +609,9 @@ 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(),
|
||||
source_rename: source_rename::State::default(),
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_version: 0,
|
||||
@@ -642,18 +658,37 @@ impl App {
|
||||
|| self.behavior.has_pending()
|
||||
}
|
||||
fn sync_source(&mut self) {
|
||||
self.sync_source_with_alias_editor(None);
|
||||
}
|
||||
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.source_workspace.accept(self.document.bundle());
|
||||
self.show_source_file(false);
|
||||
self.show_source_file_with_alias_editor(false, hint);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.show_source_file_with_alias_editor(restore_cursor, None);
|
||||
}
|
||||
fn show_source_file_with_alias_editor(
|
||||
&mut self,
|
||||
restore_cursor: bool,
|
||||
hint: Option<source_rename::EditorRefresh<'_>>,
|
||||
) {
|
||||
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();
|
||||
self.source_navigation.refresh(&source);
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
if !hint.is_some_and(|hint| {
|
||||
hint.apply(
|
||||
&mut self.source_editor,
|
||||
&self.source_workspace.active,
|
||||
&source,
|
||||
)
|
||||
}) {
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
}
|
||||
self.source_snapshot = Arc::from(source);
|
||||
self.source_jump = restore_cursor
|
||||
.then(|| {
|
||||
@@ -663,6 +698,9 @@ 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
|
||||
@@ -695,6 +733,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn sync(&mut self) {
|
||||
self.sync_with_alias_editor(None);
|
||||
}
|
||||
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
@@ -724,7 +765,7 @@ impl App {
|
||||
.map(|d| d.id);
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.sync_source();
|
||||
self.sync_source_with_alias_editor(hint);
|
||||
}
|
||||
fn normalize_creation(&mut self) {
|
||||
if !self.create_kind.supports(self.layer) {
|
||||
@@ -854,8 +895,12 @@ impl App {
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
.created_selection(self.project(), &command)
|
||||
.or_else(|| {
|
||||
self.behavior
|
||||
.topology
|
||||
.created_selection(self.project(), &command)
|
||||
});
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(created) = created {
|
||||
@@ -912,6 +957,8 @@ 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 rename_in_flight = self.source_rename.in_flight;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
@@ -923,6 +970,8 @@ 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.source_rename.in_flight = rename_in_flight;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
self.status = "Project ready".into();
|
||||
@@ -1023,7 +1072,10 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
crate::source_navigation::interaction::observe(
|
||||
crate::hosted::view::overlay(self, base),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1031,8 +1083,18 @@ impl App {
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
behavior::creation::subscription(self.behavior.creation.has_pending())
|
||||
.map(|()| Message::Escape),
|
||||
// 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.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.source_symbols.open
|
||||
|| self.source_navigation.open
|
||||
|| self.source_rename.open())
|
||||
&& self.architecture.is_none()),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
@@ -1077,6 +1139,55 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if let Message::SourceRename(event) = &message
|
||||
&& !self.source_rename_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
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::SourceRenameReady(completion) = message {
|
||||
return self.source_rename_ready(completion);
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
) {
|
||||
self.source_interaction();
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1141,7 +1252,7 @@ impl App {
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
@@ -1187,7 +1298,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1225,7 +1336,7 @@ impl App {
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
@@ -1255,6 +1366,18 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_rename.open() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.close_source_rename();
|
||||
return Task::none();
|
||||
}
|
||||
if !modal_event && !matches!(message, Message::SourceRename(_)) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
@@ -1299,6 +1422,7 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.refresh_source_rename_context();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1519,6 +1643,37 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::First(_, tool) => {
|
||||
let empty = match tool {
|
||||
scenario::Tool::Lifeline => !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline),
|
||||
scenario::Tool::Message | scenario::Tool::Action => {
|
||||
self.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline)
|
||||
&& !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Occurrence)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !empty {
|
||||
self.status="This scenario already has content. Use its current authoring tools.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
CanvasEvent::BeginOccurrence {
|
||||
tool,
|
||||
slot,
|
||||
@@ -1754,7 +1909,17 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.behavior.creation.has_pending() {
|
||||
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() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
}
|
||||
@@ -1762,6 +1927,12 @@ 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;
|
||||
}
|
||||
@@ -2321,10 +2492,13 @@ impl App {
|
||||
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
|
||||
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
|
||||
&& (self.source_dirty() || self.draft_dirty())
|
||||
{
|
||||
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();
|
||||
@@ -2342,6 +2516,11 @@ 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);
|
||||
}
|
||||
@@ -2537,13 +2716,19 @@ impl App {
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
if redo {
|
||||
self.document.redo();
|
||||
let changed = if redo {
|
||||
self.document.redo()
|
||||
} else {
|
||||
self.document.undo();
|
||||
self.document.undo()
|
||||
};
|
||||
if changed {
|
||||
self.sync();
|
||||
}
|
||||
self.sync();
|
||||
self.status = if redo {
|
||||
self.status = if !changed && redo {
|
||||
"Nothing to redo"
|
||||
} else if !changed {
|
||||
"Nothing to undo"
|
||||
} else if redo {
|
||||
"Model change redone"
|
||||
} else {
|
||||
"Model change undone"
|
||||
@@ -2783,6 +2968,7 @@ impl App {
|
||||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||||
return Task::none();
|
||||
}
|
||||
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
|
||||
self.pending_recovery = None;
|
||||
self.source_diagnostics
|
||||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||||
@@ -2796,12 +2982,21 @@ 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}"
|
||||
)
|
||||
@@ -2858,14 +3053,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
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::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::SourceRename(event) => return self.source_rename_event(event),
|
||||
Message::SourceRenameReady(completion) => return self.source_rename_ready(completion),
|
||||
Message::SourceSymbolsReady(completion) => {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
Message::ApplySource => {
|
||||
self.apply_source();
|
||||
@@ -2911,6 +3105,7 @@ 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 {
|
||||
@@ -2919,13 +3114,22 @@ impl App {
|
||||
"source-find"
|
||||
});
|
||||
}
|
||||
Event::Close => self.source_navigation.open = false,
|
||||
Event::Close => {
|
||||
let was_open = self.source_navigation.open;
|
||||
self.source_navigation.open = false;
|
||||
if was_open {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -2936,8 +3140,7 @@ impl App {
|
||||
.destination(self.source_editor.line_count())
|
||||
{
|
||||
Ok(cursor) => {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(cursor, true);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
},
|
||||
@@ -3020,6 +3223,9 @@ 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;
|
||||
@@ -3578,8 +3784,9 @@ 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.reachable = disk.reachable.clone();
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.source_version += 1;
|
||||
@@ -3631,11 +3838,13 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
self.source_jump = Some(text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
});
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn create_source_file(&mut self) -> Result<(), String> {
|
||||
@@ -4245,6 +4454,10 @@ 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::*;
|
||||
|
||||
@@ -214,6 +214,11 @@ impl App {
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
|
||||
@@ -213,6 +213,14 @@ impl App {
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
@@ -345,6 +353,14 @@ impl App {
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
//! Keep Iced's single-line input from silently filtering pasted control bytes.
|
||||
//! Only the alias field uses this adapter. The child retains editing and focus.
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(AliasInput { input, rejected })
|
||||
}
|
||||
|
||||
struct AliasInput<'a, Message, Theme, R> {
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
|
||||
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
|
||||
// previously admitted paste while V is held, until KeyReleased clears that cache.
|
||||
struct CapturedClipboard<'a> {
|
||||
content: Option<String>,
|
||||
original: &'a mut dyn Clipboard,
|
||||
}
|
||||
impl Clipboard for CapturedClipboard<'_> {
|
||||
fn read(&self, kind: clipboard::Kind) -> Option<String> {
|
||||
if kind == clipboard::Kind::Standard {
|
||||
self.content.clone()
|
||||
} else {
|
||||
self.original.read(kind)
|
||||
}
|
||||
}
|
||||
fn write(&mut self, kind: clipboard::Kind, contents: String) {
|
||||
self.original.write(kind, contents);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
|
||||
for AliasInput<'_, Message, Theme, R>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.input.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.input.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.input.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.input));
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.input.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let focused = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused();
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
|
||||
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
}
|
||||
let paste = matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
|
||||
if key.to_latin(*physical_key) == Some('v')
|
||||
&& state.modifiers.command() && !state.modifiers.alt());
|
||||
if focused
|
||||
&& paste
|
||||
&& let Some(rejected) = &self.rejected
|
||||
{
|
||||
let content = clipboard.read(clipboard::Kind::Standard);
|
||||
if content
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
// Do not invoke the child: even an empty replacement would erase
|
||||
// its selection. Approval invalidation is an explicit App event.
|
||||
shell.publish(rejected.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
&mut CapturedClipboard {
|
||||
content,
|
||||
original: clipboard,
|
||||
},
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
|
||||
// Escape branch, which also resets stored modifiers. Observe that branch
|
||||
// after its text/shortcut early returns and effective-key conversion.
|
||||
if focused
|
||||
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
|
||||
&& !tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused()
|
||||
{
|
||||
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.input.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::keyboard::{
|
||||
Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Signal {
|
||||
Input(String),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
struct ClipboardText {
|
||||
text: String,
|
||||
reads: Cell<usize>,
|
||||
}
|
||||
impl Clipboard for ClipboardText {
|
||||
fn read(&self, _: clipboard::Kind) -> Option<String> {
|
||||
self.reads.set(self.reads.get() + 1);
|
||||
Some(self.text.clone())
|
||||
}
|
||||
fn write(&mut self, _: clipboard::Kind, _: String) {}
|
||||
}
|
||||
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
|
||||
|
||||
struct Mounted {
|
||||
input: Element<'static, Signal, iced::Theme, FontLayout>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
clipboard: ClipboardText,
|
||||
messages: Vec<Signal>,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
|
||||
let mut child = iced::widget::text_input("Alias", "send_water");
|
||||
if enabled {
|
||||
child = child.on_input(Signal::Input);
|
||||
}
|
||||
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let node = input.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
|
||||
);
|
||||
if focused {
|
||||
let state = tree.children[0].state.downcast_mut::<InputState>();
|
||||
state.focus();
|
||||
state.select_range(0, 10);
|
||||
}
|
||||
let mut mounted = Self {
|
||||
input,
|
||||
tree,
|
||||
node,
|
||||
clipboard: ClipboardText {
|
||||
text: "foo\nbar".into(),
|
||||
reads: Cell::new(0),
|
||||
},
|
||||
messages: vec![],
|
||||
};
|
||||
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
modifiers,
|
||||
)));
|
||||
mounted
|
||||
}
|
||||
fn state(&self) -> &InputState {
|
||||
self.tree.children[0].state.downcast_ref::<InputState>()
|
||||
}
|
||||
fn event(&mut self, event: Event) -> bool {
|
||||
let mut shell = Shell::new(&mut self.messages);
|
||||
self.input.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
|
||||
);
|
||||
shell.is_event_captured()
|
||||
}
|
||||
}
|
||||
fn key(key: Key, modified_key: Key, code: Code) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key: Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn paste() -> Event {
|
||||
key(
|
||||
Key::Character("v".into()),
|
||||
Key::Character("v".into()),
|
||||
Code::KeyV,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
|
||||
for focused in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::COMMAND,
|
||||
Modifiers::COMMAND | Modifiers::ALT,
|
||||
Modifiers::COMMAND | Modifiers::SHIFT,
|
||||
] {
|
||||
let mut mounted = Mounted::new(focused, enabled, modifiers);
|
||||
let before = mounted.state().cursor();
|
||||
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
|
||||
let captured = mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
if expected {
|
||||
vec![Signal::Rejected]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
if expected {
|
||||
assert!(captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_escape_reset_follows_child_after_early_returns() {
|
||||
// Raw Escape mapped to Home must not clear only the guard's Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Escape),
|
||||
Key::Named(Named::Home),
|
||||
Code::Escape,
|
||||
));
|
||||
assert!(mounted.state().is_focused());
|
||||
assert!(mounted.event(paste()));
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
|
||||
// Effective Escape clears both states; a later focus operation does not
|
||||
// invent a new ModifiersChanged event or revive the previous Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
));
|
||||
assert!(!mounted.state().is_focused());
|
||||
mounted.tree.children[0]
|
||||
.state
|
||||
.downcast_mut::<InputState>()
|
||||
.focus();
|
||||
mounted.event(paste());
|
||||
assert!(mounted.messages.is_empty());
|
||||
assert_eq!(mounted.clipboard.reads.get(), 0);
|
||||
|
||||
// Printable text returns before effective Escape and leaves modifiers live.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
let mut event = key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
);
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
|
||||
*text = Some("x".into());
|
||||
}
|
||||
mounted.event(event);
|
||||
assert!(mounted.state().is_focused());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_paste_cache_contains_only_previously_admitted_text() {
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.clipboard.text = "water".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.clipboard.text = "other".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwater".into())],
|
||||
"Iced reuses its first admitted paste while the key is held"
|
||||
);
|
||||
mounted.messages.clear();
|
||||
let before = mounted.state().cursor();
|
||||
mounted.clipboard.text = "bad\tvalue".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
mounted.messages.clear();
|
||||
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: Key::Character("v".into()),
|
||||
modified_key: Key::Character("v".into()),
|
||||
physical_key: Physical::Code(Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
}));
|
||||
mounted.clipboard.text = "later".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwaterlater".into())]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Display-only reuse after a guarded alias rename. This is not model authority.
|
||||
use iced::{advanced::text::Renderer, widget::text_editor};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Replace exact, unchanged-line-count source in an existing editor.
|
||||
///
|
||||
/// The caller must first authorize/commit the model change and must reconstruct
|
||||
/// from accepted source on false. Preflight failures do not mutate the editor;
|
||||
/// a failed final readback can leave it changed. On changing success the cursor
|
||||
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
|
||||
/// Direct Content actions deliberately bypass App draft edits and Undo history.
|
||||
pub(super) fn replace<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
before: &str,
|
||||
after: &str,
|
||||
) -> bool {
|
||||
if editor.text() != before {
|
||||
return false;
|
||||
}
|
||||
let Some(old_lines) = lines(before) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_lines) = lines(after) else {
|
||||
return false;
|
||||
};
|
||||
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
|
||||
return false;
|
||||
}
|
||||
let mut patches = Vec::new();
|
||||
for (line, ((old, old_ending), (new, new_ending))) in
|
||||
old_lines.into_iter().zip(new_lines).enumerate()
|
||||
{
|
||||
let Some(actual) = editor.line(line) else {
|
||||
return false;
|
||||
};
|
||||
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
|
||||
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
|
||||
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
|
||||
return false;
|
||||
}
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let mut start = old
|
||||
.bytes()
|
||||
.zip(new.bytes())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut suffix = old[start..]
|
||||
.bytes()
|
||||
.rev()
|
||||
.zip(new[start..].bytes().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
|
||||
{
|
||||
suffix -= 1;
|
||||
}
|
||||
// All positions and owned payloads are prepared before the first edit.
|
||||
patches.push((
|
||||
line,
|
||||
start,
|
||||
old.len() - suffix,
|
||||
Arc::new(new[start..new.len() - suffix].to_owned()),
|
||||
));
|
||||
}
|
||||
if patches.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let empty = Arc::new(String::new());
|
||||
for (line, start, end, replacement) in patches.into_iter().rev() {
|
||||
select(editor, line, start, end);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
replacement,
|
||||
)));
|
||||
}
|
||||
// Iced overwrites its earliest-change marker on each Edit. An explicit
|
||||
// zero-width selection + empty Paste changes no bytes, but marks line zero.
|
||||
// This also refreshes previously unhighlighted lines before the first patch.
|
||||
// It resets only line zero's text cache; other unchanged lines retain theirs.
|
||||
select(editor, 0, 0, 0);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
|
||||
editor.text() == after
|
||||
}
|
||||
|
||||
fn select<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
line: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) {
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line,
|
||||
column: start,
|
||||
},
|
||||
// move_to(None) does not clear a previous selection in this Iced version.
|
||||
selection: Some(text_editor::Position { line, column: end }),
|
||||
});
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
|
||||
use text_editor::LineEnding;
|
||||
let mut result = Vec::new();
|
||||
let mut parts = source.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let (text, ending) = if parts.peek().is_none() {
|
||||
(part, LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, LineEnding::CrLf)
|
||||
} else {
|
||||
(part, LineEnding::Lf)
|
||||
};
|
||||
if text.contains('\r')
|
||||
|| (text.is_empty()
|
||||
&& ending == LineEnding::CrLf
|
||||
&& result
|
||||
.last()
|
||||
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
result.push((text, ending));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{layout, text::Highlighter, widget::Tree};
|
||||
use iced::{Element, Font, Length, Size};
|
||||
use std::{cell::RefCell, ops::Range};
|
||||
|
||||
#[test]
|
||||
fn exact_text_matrix_and_reverse_preserve_endings() {
|
||||
for (before, after) in [
|
||||
("", ""),
|
||||
("", "alias"),
|
||||
("alias", ""),
|
||||
("same", "same"),
|
||||
("a\n", "alias\n"),
|
||||
("a\r\n", "alias\r\n"),
|
||||
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
|
||||
("\na\n\n", "\nlong_alias\n\n"),
|
||||
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
|
||||
(
|
||||
"雨🙂 e\u{301} @a @a // a",
|
||||
"雨🙂 e\u{301} @a_long @a_long // a",
|
||||
),
|
||||
("@abc", "@abcdef"),
|
||||
("@abcdef", "@abc"),
|
||||
("@abc_suffix", "@def_suffix"),
|
||||
("@none", "@identifier"),
|
||||
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
|
||||
// not be used as String split positions, even outside alias scope.
|
||||
("雨xé", "雪xê"),
|
||||
("é", "©"),
|
||||
(
|
||||
"unchanged\n@a\nunchanged\n@a\n",
|
||||
"unchanged\n@long\nunchanged\n@long\n",
|
||||
),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
assert!(
|
||||
replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), after);
|
||||
let expected = text_editor::Content::<FontLayout>::with_text(after);
|
||||
assert_eq!(editor.line_count(), expected.line_count());
|
||||
for (actual, expected) in editor.lines().zip(expected.lines()) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
assert_eq!(actual.ending, expected.ending);
|
||||
}
|
||||
if before != after {
|
||||
assert_eq!(
|
||||
editor.cursor().position,
|
||||
text_editor::Position { line: 0, column: 0 }
|
||||
);
|
||||
assert_eq!(editor.cursor().selection, None);
|
||||
}
|
||||
assert!(replace(&mut editor, after, before));
|
||||
assert_eq!(editor.text(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
|
||||
for (actual, before, after) in [
|
||||
("live", "stale", "changed"),
|
||||
("a\nb", "a\nb", "long\nb\nc"),
|
||||
("a\nb", "a\nb", "long\r\nb"),
|
||||
// Refusal on a later line must not apply the earlier planned edit.
|
||||
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
|
||||
("a\rb", "a\rb", "long\rb"),
|
||||
("a\n\rb", "a\n\rb", "long\n\rb"),
|
||||
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
|
||||
// The replacement must not introduce a greedy LFCR boundary either.
|
||||
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
|
||||
("a", "a", "long\r"),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
|
||||
select(&mut editor, 0, 0, 1);
|
||||
let cursor = editor.cursor();
|
||||
let selection = editor.selection();
|
||||
assert!(
|
||||
!replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), actual);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.selection(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
|
||||
select(&mut editor, 0, 1, 2);
|
||||
let cursor = editor.cursor();
|
||||
assert!(replace(&mut editor, "@a", "@a"));
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert!(replace(&mut editor, "@a", "@long"));
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
|
||||
assert_eq!(editor.text(), "@long!");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Tracked(crate::highlight::ArcadiaHighlighter);
|
||||
impl Highlighter for Tracked {
|
||||
type Settings = Arc<str>;
|
||||
type Highlight = oa_core::language::TokenClass;
|
||||
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
|
||||
fn new(settings: &Self::Settings) -> Self {
|
||||
Self(crate::highlight::ArcadiaHighlighter::new(settings))
|
||||
}
|
||||
fn update(&mut self, settings: &Self::Settings) {
|
||||
self.0.update(settings);
|
||||
}
|
||||
fn change_line(&mut self, line: usize) {
|
||||
CHANGES.with(|changes| changes.borrow_mut().push(line));
|
||||
self.0.change_line(line);
|
||||
}
|
||||
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||
self.0.highlight_line(line)
|
||||
}
|
||||
fn current_line(&self) -> usize {
|
||||
self.0.current_line()
|
||||
}
|
||||
}
|
||||
|
||||
fn view(
|
||||
editor: &text_editor::Content<FontLayout>,
|
||||
source: Arc<str>,
|
||||
) -> Element<'_, (), iced::Theme, FontLayout> {
|
||||
text_editor(editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<Tracked>(source, crate::highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
|
||||
let before = "// earlier\n// unmodified\n@a";
|
||||
let after = "// earlier\n// unmodified\n@none";
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
tree
|
||||
};
|
||||
CHANGES.with(|changes| changes.borrow_mut().clear());
|
||||
assert!(replace(&mut editor, before, after));
|
||||
{
|
||||
let mut widget = view(&editor, Arc::from(after));
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
}
|
||||
// Font/metrics/bounds are already stable. This notification must come
|
||||
// from the helper's empty edit, not a fresh renderer initialization.
|
||||
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
|
||||
assert_eq!(editor.text(), after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Real paragraph advances and clipping through the actual preview dialog.
|
||||
//! Native screenshots additionally check raster ink and the platform renderer.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct Glyph {
|
||||
text: String,
|
||||
paragraph_y: u32,
|
||||
line: usize,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
|
||||
struct GlyphRecorder {
|
||||
wanted: BTreeSet<String>,
|
||||
transforms: Vec<Transformation>,
|
||||
clips: Vec<Rectangle>,
|
||||
glyphs: Vec<Glyph>,
|
||||
quads: Vec<Rectangle>,
|
||||
}
|
||||
impl GlyphRecorder {
|
||||
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
|
||||
Self {
|
||||
wanted,
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clips: vec![viewport],
|
||||
glyphs: vec![],
|
||||
quads: vec![],
|
||||
}
|
||||
}
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for GlyphRecorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clips = vec![bounds];
|
||||
self.glyphs.clear();
|
||||
self.quads.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
|
||||
self.quads.push(quad.bounds * self.transform());
|
||||
}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for GlyphRecorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let text = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !self.wanted.contains(&text) {
|
||||
return;
|
||||
}
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
if run.text[glyph.start..glyph.end]
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounds = Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform();
|
||||
self.glyphs.push(Glyph {
|
||||
text: text.clone(),
|
||||
paragraph_y: position.y.to_bits(),
|
||||
line: run.line_i,
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds,
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(
|
||||
&mut self,
|
||||
editor: &Self::Editor,
|
||||
position: Point,
|
||||
color: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
|
||||
}
|
||||
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
|
||||
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollGeometry {
|
||||
id: Option<widget::Id>,
|
||||
bounds: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
offset: Vector,
|
||||
}
|
||||
impl widget::Operation for ScrollGeometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == self.id.as_ref() {
|
||||
self.bounds = Some(bounds);
|
||||
self.content = Some(content);
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
|
||||
text.split('\n')
|
||||
.enumerate()
|
||||
.flat_map(|(line, text)| {
|
||||
text.char_indices()
|
||||
.filter(|(_, ch)| !ch.is_whitespace())
|
||||
.map(move |(byte, _)| (line, byte))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declarations(long: bool) -> App {
|
||||
let name = if long {
|
||||
format!("Assess {} request", "界🚀".repeat(90))
|
||||
} else {
|
||||
"Assess 救援 request".into()
|
||||
};
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
|
||||
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
|
||||
]) };
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "救援/assess.arcadia".into();
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.show_source_file(false);
|
||||
let start = app
|
||||
.source_workspace
|
||||
.text()
|
||||
.find("oa_activity assess")
|
||||
.unwrap()
|
||||
+ "oa_activity ".len();
|
||||
app.source_editor.move_to(cursor(
|
||||
app.source_workspace.text(),
|
||||
start,
|
||||
start + "assess".len(),
|
||||
));
|
||||
begin(&mut app);
|
||||
let replacement = if long {
|
||||
"a".repeat(255)
|
||||
} else {
|
||||
"assess_damage".into()
|
||||
};
|
||||
input(&mut app, &replacement);
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_page_glyphs_visible(app: &App, host: Size) {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let ready = form.approval.as_ref().unwrap();
|
||||
let file = &ready.preview.files()[form.file];
|
||||
let mut expected = BTreeMap::<String, usize>::new();
|
||||
for line in file.hunks()[form.hunk]
|
||||
.lines()
|
||||
.iter()
|
||||
.skip(form.line)
|
||||
.take(LINES_PER_PAGE)
|
||||
{
|
||||
for source in [
|
||||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
] {
|
||||
if let Some(fragment) = preview::text_segment(source, form.segment) {
|
||||
let text = normalized(fragment);
|
||||
if !required_chars(&text).is_empty() {
|
||||
*expected.entry(text).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
let viewport = Rectangle::new(Point::ORIGIN, host);
|
||||
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
|
||||
let editor = text_editor::Content::<GlyphRecorder>::new();
|
||||
let mut view = dialog_content(app, &editor);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
|
||||
let mut geometry = ScrollGeometry {
|
||||
id: Some(preview_scroll_id(form, ready.id)),
|
||||
..ScrollGeometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
let bounds = geometry.bounds.unwrap();
|
||||
let content = geometry.content.unwrap();
|
||||
assert!(bounds.height >= 40.);
|
||||
let maximum = (content.height - bounds.height).max(0.);
|
||||
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
|
||||
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
|
||||
let mut reserved_bar_gap = true;
|
||||
for step in 0..=steps {
|
||||
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
assert!(
|
||||
(geometry.offset.y - offset).abs() <= 0.5,
|
||||
"actual scroll operation reaches the requested source offset"
|
||||
);
|
||||
renderer::Renderer::reset(&mut renderer, viewport);
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
// The vertical scrollbar's actual painted thumb fixes its excluded x
|
||||
// region; the outer scroll clip alone cannot detect overpainting.
|
||||
let scrollbar_left = renderer
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|quad| {
|
||||
quad.width > 0.
|
||||
&& quad.width <= 16.
|
||||
&& quad.height > 0.
|
||||
&& quad.x >= bounds.x + bounds.width - 18.
|
||||
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
|
||||
&& quad.y >= bounds.y - 0.5
|
||||
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
|
||||
})
|
||||
.map(|quad| quad.x)
|
||||
.min_by(f32::total_cmp);
|
||||
if maximum > 0. {
|
||||
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
|
||||
reserved_bar_gap &= content.x + content.width <= left - 7.5;
|
||||
}
|
||||
for glyph in &renderer.glyphs {
|
||||
let right = glyph.bounds.x + glyph.bounds.width;
|
||||
if let Some(left) = scrollbar_left {
|
||||
assert!(
|
||||
right <= left - 0.5,
|
||||
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
|
||||
glyph.text,
|
||||
glyph.bytes,
|
||||
glyph.bounds
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
|
||||
"source glyph escaped reserved width: {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds
|
||||
);
|
||||
assert!(
|
||||
glyph.bounds.x >= glyph.clip.x - 0.5
|
||||
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
|
||||
"source glyph horizontally clipped: {:?} {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds,
|
||||
glyph.clip
|
||||
);
|
||||
if glyph.bounds.y >= glyph.clip.y - 0.5
|
||||
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
|
||||
{
|
||||
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
|
||||
let chars = logical_line
|
||||
.char_indices()
|
||||
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
|
||||
.map(|(byte, _)| (glyph.line, byte));
|
||||
visible
|
||||
.entry((glyph.text.clone(), glyph.paragraph_y))
|
||||
.or_default()
|
||||
.extend(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reserved_bar_gap,
|
||||
"source content must reserve actual scrollbar width plus gap"
|
||||
);
|
||||
assert!(
|
||||
bounds.width >= host.width - 30.,
|
||||
"preview must use available dialog width: {bounds:?} / {host:?}"
|
||||
);
|
||||
for (text, count) in expected {
|
||||
let required = required_chars(&text);
|
||||
let instances: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|((value, _), _)| value == &text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
instances.len(),
|
||||
count,
|
||||
"all before/after occurrences must be seen: {text:?}"
|
||||
);
|
||||
for (_, seen) in instances {
|
||||
assert_eq!(
|
||||
seen, &required,
|
||||
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
|
||||
for long in [false, true] {
|
||||
let mut app = declarations(long);
|
||||
let ready = app
|
||||
.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
let before = evidence(&app);
|
||||
for (width, height) in [(1100., 700.), (580., 700.)] {
|
||||
app.window_size = Size::new(width, height);
|
||||
let host = Size::new((width - 84.).min(708.), height - 120.);
|
||||
let total = segment_total(app.source_rename.form.as_ref().unwrap());
|
||||
if long {
|
||||
assert!(
|
||||
total >= 3,
|
||||
"include unbroken ASCII and wide Unicode across segments"
|
||||
);
|
||||
}
|
||||
for segment in 0..total {
|
||||
assert_page_glyphs_visible(&app, host);
|
||||
if segment + 1 < total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, true));
|
||||
}
|
||||
}
|
||||
for _ in 1..total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, false));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
evidence(&app),
|
||||
before,
|
||||
"preview geometry/paging cannot alter transaction inputs"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
ready.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
const HOST: Size = Size::new(720., 660.);
|
||||
|
||||
fn paged_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let name = "Long engineering name 雨 ".repeat(18);
|
||||
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
||||
..,
|
||||
&format!(
|
||||
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
||||
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
||||
),
|
||||
);
|
||||
let mut uses = String::new();
|
||||
for index in 0..16 {
|
||||
if index == 12 {
|
||||
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
||||
}
|
||||
uses.push_str(&format!(
|
||||
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
||||
));
|
||||
}
|
||||
source.files.insert("uses.arcadia".into(), uses);
|
||||
let mut app = fixture();
|
||||
app.window_size = HOST;
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
||||
app.source_editor
|
||||
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
||||
begin(&mut app);
|
||||
input(&mut app, "pump_long");
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn scroll_id(app: &App) -> widget::Id {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
||||
}
|
||||
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
editor: text_editor::Content<FontLayout>,
|
||||
tree: widget::Tree,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(app: &App) -> Self {
|
||||
let editor = text_editor::Content::<FontLayout>::new();
|
||||
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
||||
Self { editor, tree }
|
||||
}
|
||||
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
// Preserve the actual scroll state across the new page's view diff.
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
||||
}
|
||||
fn offset(&mut self, app: &App) -> f32 {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
}
|
||||
fn scroll_down(&mut self, app: &App) -> f32 {
|
||||
self.operate(
|
||||
app,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
scroll_id(app),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(10_000.),
|
||||
},
|
||||
),
|
||||
);
|
||||
let offset = self.offset(app);
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("preview paging must only schedule a widget operation");
|
||||
};
|
||||
self.operate(app, operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let old_id = scroll_id(&app);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_ne!(old_id, scroll_id(&app));
|
||||
// Negative control: even after diff/layout with the new id and new file,
|
||||
// Iced retains the previous offset until the returned operation executes.
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
|
||||
for navigation in [
|
||||
Event::Line(invocation, approval, true),
|
||||
Event::Segment(invocation, approval, true),
|
||||
Event::Hunk(invocation, approval, true),
|
||||
Event::File(invocation, approval, false),
|
||||
Event::Search(invocation, "uses".into()),
|
||||
Event::Search(invocation, "no-path-match".into()),
|
||||
Event::Search(invocation, String::new()),
|
||||
] {
|
||||
mounted.scroll_down(&app);
|
||||
let task = app.update(Message::SourceRename(navigation));
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Input(
|
||||
invocation,
|
||||
"pump_long".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"clamped first-page request is a no-op"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let old_approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let old_page_task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
||||
|
||||
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"defs".into(),
|
||||
)));
|
||||
input(&mut app, "pump_new");
|
||||
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
||||
assert_ne!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
old_approval
|
||||
);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old approval target is absent"
|
||||
);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"stale envelope is rejected before scheduling"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
|
||||
let first_visit = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let next_page = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
||||
let return_visit = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old visit to the identical page is stale"
|
||||
);
|
||||
|
||||
let old_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"uses".into(),
|
||||
)));
|
||||
let new_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"us".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old filter on the same page is stale"
|
||||
);
|
||||
|
||||
input(&mut app, "pump_new");
|
||||
let job = start_preview(&mut app);
|
||||
let completion = preview_completion(&app, job);
|
||||
// No intermediate frame mounts the temporary no-approval view. The old
|
||||
// Scrollable survives even though the new approval has a different id.
|
||||
let ready = app.update(Message::SourceRenameReady(completion));
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, ready).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
|
||||
//! Child of source_rename_timing_tests, preserving the separate real App test.
|
||||
use super::*;
|
||||
use crate::{highlight, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{
|
||||
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
|
||||
};
|
||||
|
||||
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
|
||||
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
|
||||
#[derive(Default)]
|
||||
struct ObservedFontLayout {
|
||||
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
|
||||
}
|
||||
impl renderer::Renderer for ObservedFontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {
|
||||
self.drawn = None;
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for ObservedFontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
|
||||
self.drawn = Some((editor.downgrade(), position, clip));
|
||||
}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
type Content = text_editor::Content<ObservedFontLayout>;
|
||||
const HOST: Size = Size::new(640., 350.);
|
||||
|
||||
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
|
||||
text_editor(editor)
|
||||
.on_action(|_| ())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
|
||||
assert_eq!(editor.text(), expected);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
let lines: Vec<_> = expected.split('\n').collect();
|
||||
assert_eq!(editor.line_count(), lines.len());
|
||||
for (index, part) in lines.iter().enumerate() {
|
||||
let (text, ending) = if index + 1 == lines.len() {
|
||||
(*part, text_editor::LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, text_editor::LineEnding::CrLf)
|
||||
} else {
|
||||
(*part, text_editor::LineEnding::Lf)
|
||||
};
|
||||
let actual = editor.line(index).unwrap();
|
||||
assert_eq!(actual.text, text);
|
||||
assert_eq!(actual.ending, ending);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
|
||||
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
|
||||
let editor = weak.upgrade().expect("drawn editor is still live");
|
||||
let buffer = editor.buffer();
|
||||
assert_eq!(weak.bounds, Size::new(608., 318.));
|
||||
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
|
||||
assert_eq!(buffer.metrics().font_size, 14.);
|
||||
assert_eq!(
|
||||
buffer.scroll().horizontal,
|
||||
0.,
|
||||
"fixture origin fits the viewport"
|
||||
);
|
||||
let start = cursor.position;
|
||||
let end = cursor.selection.expect("approved alias selection");
|
||||
assert_eq!(start.line, end.line);
|
||||
assert_eq!(end.column - start.column, alias.len());
|
||||
let line = &buffer.lines[start.line];
|
||||
assert_eq!(&line.text()[start.column..end.column], alias);
|
||||
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
|
||||
let font = graphics::text::to_attributes(Font::MONOSPACE);
|
||||
for byte in start.column..end.column {
|
||||
let attrs = line.attrs_list().get_span(byte);
|
||||
assert_eq!(
|
||||
attrs.color_opt,
|
||||
Some(color),
|
||||
"accepted alias syntax attributes"
|
||||
);
|
||||
assert_eq!(attrs.family, font.family);
|
||||
}
|
||||
let mut covered = vec![false; alias.len()];
|
||||
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
|
||||
for glyph in run.glyphs {
|
||||
let from = glyph.start.max(start.column);
|
||||
let to = glyph.end.min(end.column);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
|
||||
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
|
||||
let y = position.y + run.line_top;
|
||||
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
|
||||
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
|
||||
covered[from - start.column..to - start.column].fill(true);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.into_iter().all(|seen| seen),
|
||||
"entire alias has visible shaped glyphs"
|
||||
);
|
||||
// This temporary strong upgrade is dropped before the next editor mutation.
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Arm {
|
||||
Constructor,
|
||||
Reuse,
|
||||
}
|
||||
impl Arm {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Constructor => "with_text",
|
||||
Self::Reuse => "same_line_reuse",
|
||||
}
|
||||
}
|
||||
fn phases(self) -> [&'static str; 4] {
|
||||
match self {
|
||||
Self::Constructor => [
|
||||
"constructor_replace_origin",
|
||||
"constructor_next_layout",
|
||||
"constructor_highlight_draw",
|
||||
"constructor_combined",
|
||||
],
|
||||
Self::Reuse => [
|
||||
"reuse_replace_origin",
|
||||
"reuse_next_layout",
|
||||
"reuse_highlight_draw",
|
||||
"reuse_combined",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trial(
|
||||
fixture: &Fixture,
|
||||
cursor: text_editor::Cursor,
|
||||
arm: Arm,
|
||||
trial: usize,
|
||||
samples: &mut Samples,
|
||||
) {
|
||||
let before = &fixture.bundle.files[ORIGIN];
|
||||
let after = &fixture.expected_bundle.files[ORIGIN];
|
||||
let mut editor = Content::with_text(before);
|
||||
editor.move_to(fixture.cursor);
|
||||
let mut renderer = ObservedFontLayout::default();
|
||||
let theme = iced::Theme::Light;
|
||||
let defaults = renderer::Style::default();
|
||||
let viewport = Rectangle::with_size(HOST);
|
||||
let limits = layout::Limits::new(Size::ZERO, HOST);
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before.as_str()));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
tree.state
|
||||
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
|
||||
.focus();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
tree
|
||||
};
|
||||
check_text(&editor, before, fixture.cursor);
|
||||
check_draw(&renderer, fixture.cursor, OLD);
|
||||
// No recorder Weak/Strong from setup may perturb replacement ownership.
|
||||
renderer.drawn = None;
|
||||
let next_source: Arc<str> = Arc::from(after.as_str());
|
||||
|
||||
// Only normal operations and four timestamps occur between start and end.
|
||||
// Source/model oracles, logging and recorder glyph inspection are outside.
|
||||
let started = Instant::now();
|
||||
let replaced = match arm {
|
||||
Arm::Constructor => {
|
||||
editor = Content::with_text(after);
|
||||
true
|
||||
}
|
||||
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
|
||||
};
|
||||
editor.move_to(cursor);
|
||||
let replaced_at = Instant::now();
|
||||
let mut widget = view(&editor, next_source);
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
let laid_out_at = Instant::now();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let drawn_at = Instant::now();
|
||||
drop(widget);
|
||||
|
||||
assert!(replaced, "reuse must not silently time a fallback/refusal");
|
||||
check_text(&editor, after, cursor);
|
||||
check_draw(&renderer, cursor, NEW);
|
||||
renderer.drawn = None;
|
||||
for (phase, duration) in arm.phases().into_iter().zip([
|
||||
replaced_at.duration_since(started),
|
||||
laid_out_at.duration_since(replaced_at),
|
||||
drawn_at.duration_since(laid_out_at),
|
||||
drawn_at.duration_since(started),
|
||||
]) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
|
||||
"trial": trial, "warmup": trial == 0,
|
||||
"elapsed_ns": duration.as_nanos().to_string(),
|
||||
"elapsed_ms": duration.as_secs_f64() * 1000.0,
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
samples.0.entry(phase).or_default().push(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
|
||||
let fixture = Fixture::load();
|
||||
// Resolve the exact production approval and verify full Project/source once,
|
||||
// outside all intervals. The actual App Confirm evaluator remains separate.
|
||||
let cursor = {
|
||||
let app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
|
||||
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
|
||||
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
|
||||
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
|
||||
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
|
||||
})
|
||||
);
|
||||
let mut samples = Samples::default();
|
||||
for index in 0..=5 {
|
||||
let arms = if index % 2 == 0 {
|
||||
[Arm::Constructor, Arm::Reuse]
|
||||
} else {
|
||||
[Arm::Reuse, Arm::Constructor]
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
|
||||
);
|
||||
for arm in arms {
|
||||
trial(&fixture, cursor, arm, index, &mut samples);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Manual timing through the real Document and App paths. No disk writes,
|
||||
//! executor polling, layout, GPU work or first-paint measurement occurs here.
|
||||
//! Register as a test child of app::source_rename to use its actual worker helpers.
|
||||
use super::*;
|
||||
use crate::source_workspace::Workspace;
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[path = "source_rename_layout_timing_tests.rs"]
|
||||
mod layout_timing_tests;
|
||||
|
||||
const OLD: &str = "oa_restore_work";
|
||||
const NEW: &str = "oa_restore_pressure";
|
||||
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
|
||||
const ORIGIN: &str = "traceability.arcadia";
|
||||
|
||||
struct Fixture {
|
||||
bundle: SourceBundle,
|
||||
project: Project,
|
||||
expected_bundle: SourceBundle,
|
||||
expected_project: Project,
|
||||
target: Id,
|
||||
cursor: text_editor::Cursor,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn load() -> Self {
|
||||
// The optional entry selects a parent-pinned copy of this exact fixture,
|
||||
// not an arbitrary performance generator or a mutable GUI destination.
|
||||
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/release1/project.arcadia")
|
||||
});
|
||||
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
|
||||
let bundle = loaded.reachable_bundle();
|
||||
assert_eq!(
|
||||
bundle.files.len(),
|
||||
30,
|
||||
"use the preserved release1 walkthrough"
|
||||
);
|
||||
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no stabilization/migration"
|
||||
);
|
||||
assert!(!document.dirty());
|
||||
let project = document.project().clone();
|
||||
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
|
||||
assert_eq!(project.elements[&target].alias, OLD);
|
||||
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
|
||||
let mut expected_project = project.clone();
|
||||
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
|
||||
|
||||
// Independent literal source oracle, authored before running the rename.
|
||||
// Do not replace the alias substring in filenames or the trace's alias.
|
||||
let mut expected_bundle = bundle.clone();
|
||||
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
|
||||
assert_eq!(
|
||||
declarations.matches("oa_activity oa_restore_work ").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
|
||||
*declarations = declarations
|
||||
.replace(
|
||||
"oa_activity oa_restore_work ",
|
||||
"oa_activity oa_restore_pressure ",
|
||||
)
|
||||
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
|
||||
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
|
||||
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
|
||||
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
|
||||
let source = &bundle.files[ORIGIN];
|
||||
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
|
||||
let position = |offset| {
|
||||
let prefix = &source[..offset];
|
||||
text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix
|
||||
.rsplit_once('\n')
|
||||
.map_or(prefix.len(), |(_, tail)| tail.len()),
|
||||
}
|
||||
};
|
||||
let cursor = text_editor::Cursor {
|
||||
position: position(start),
|
||||
selection: Some(position(start + OLD.len())),
|
||||
};
|
||||
assert!(nav::exact_cursor(source, cursor));
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
|
||||
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
|
||||
"identities": project.identities().len(), "active_file": ORIGIN,
|
||||
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
|
||||
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
|
||||
})
|
||||
);
|
||||
Self {
|
||||
bundle,
|
||||
project,
|
||||
expected_bundle,
|
||||
expected_project,
|
||||
target,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn app(&self) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = ORIGIN.into();
|
||||
app.selected = app.project().id;
|
||||
app.page = Page::Text;
|
||||
app.sync();
|
||||
app.source_editor.move_to(self.cursor);
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.rename_admission().is_ok());
|
||||
app
|
||||
}
|
||||
|
||||
fn check_candidate(&self, prepared: &PreparedAliasRename) {
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.before().bundle, self.bundle);
|
||||
assert_eq!(prepared.before().decoded.project, self.project);
|
||||
assert_eq!(prepared.after().bundle, self.expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, self.expected_project);
|
||||
}
|
||||
|
||||
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
|
||||
assert!(
|
||||
std::ptr::eq(app.document.compiled(), candidate_address),
|
||||
"adopt exact candidate allocation"
|
||||
);
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
|
||||
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert!(
|
||||
!app.document.dirty(),
|
||||
"live known saved baseline remains authoritative"
|
||||
);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"exactly one preceding model transaction"
|
||||
);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
|
||||
|
||||
impl Samples {
|
||||
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
|
||||
let started = Instant::now();
|
||||
let result = action();
|
||||
let elapsed = started.elapsed();
|
||||
// Printing, assertions and dropping the returned value are outside this
|
||||
// interval. Full Confirm still includes production's own local drops.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "timing", "phase": phase, "trial": trial,
|
||||
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
|
||||
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
self.0.entry(phase).or_default().push(elapsed);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn report(mut self) {
|
||||
for (phase, values) in &mut self.0 {
|
||||
assert_eq!(values.len(), 5);
|
||||
values.sort();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "summary", "phase": phase, "samples": values.len(),
|
||||
"min_ms": values[0].as_secs_f64() * 1000.0,
|
||||
"median_ms": values[2].as_secs_f64() * 1000.0,
|
||||
"max_ms": values[4].as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(app: &App, context: &Context) -> Target {
|
||||
let snapshot = app
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap();
|
||||
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
|
||||
resolve_target(&snapshot, context, offset).unwrap()
|
||||
}
|
||||
|
||||
fn active_job(app: &App) -> Job {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let id = form.pending.unwrap();
|
||||
assert_eq!(app.source_rename.in_flight, Some(id));
|
||||
Job {
|
||||
id,
|
||||
invocation: form.invocation,
|
||||
generation: form.generation,
|
||||
context: form.context.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
assert_eq!(target.id, fixture.target);
|
||||
let request = samples
|
||||
.time("document_request_mint", trial, || {
|
||||
app.document.alias_rename_request(target.id, NEW.into())
|
||||
})
|
||||
.unwrap();
|
||||
let prepared = samples
|
||||
.time("worker_document_prepare", trial, || request.prepare())
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = samples
|
||||
.time("worker_preview_and_origin", trial, || {
|
||||
prepare_approval(prepared, &target, ORIGIN)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(approval.preview.files().len(), 2);
|
||||
assert_eq!(approval.preview.changed_line_count(), 6);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
// Same-destination save completion is a legitimate live update after Preview.
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let changed = samples
|
||||
.time("document_guarded_commit", trial, || {
|
||||
app.document.commit_alias_rename(&approval.prepared)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(changed);
|
||||
samples.time("app_sync", trial, || app.sync());
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let begin = app.source_rename_message();
|
||||
// These Tasks are deliberately not polled: deterministic worker helpers
|
||||
// supply the same results through the real readiness messages below.
|
||||
drop(app.update(begin));
|
||||
let target_job = active_job(&app);
|
||||
let target = Arc::new(resolve(&app, &target_job.context));
|
||||
assert_eq!(target.id, fixture.target);
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: target_job,
|
||||
payload: Payload::Target(Ok(target.clone())),
|
||||
})));
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
|
||||
let generation = app.source_rename.form.as_ref().unwrap().generation;
|
||||
drop(app.update(Message::SourceRename(Event::Preview(
|
||||
invocation, generation,
|
||||
))));
|
||||
let preview_job = active_job(&app);
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
|
||||
drop(target);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
let expected_cursor = approval.cursor;
|
||||
let approval_id = approval.id;
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: preview_job,
|
||||
payload: Payload::Preview(Ok(approval)),
|
||||
})));
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
approval_id
|
||||
);
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
|
||||
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
|
||||
samples.time("confirm_returned_task_drop_without_polling", trial, || {
|
||||
drop(task)
|
||||
});
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(app.source_rename_undo_message().is_some());
|
||||
assert_eq!(app.source_editor.cursor(), expected_cursor);
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
|
||||
let fixture = Fixture::load();
|
||||
let mut samples = Samples::default();
|
||||
for trial in 0..=5 {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "record": "order", "trial": trial,
|
||||
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
|
||||
);
|
||||
if trial % 2 == 0 {
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
} else {
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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.source_rename.open()
|
||||
&& !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.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::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 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 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
@@ -14,11 +14,11 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
pub(crate) mod scenario;
|
||||
// Projection acceptance precedes the coordinated scenario renderer integration.
|
||||
#[cfg(test)]
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
pub(crate) mod state_edit;
|
||||
pub(crate) mod state_menu;
|
||||
@@ -742,6 +742,29 @@ pub enum Event {
|
||||
Submit,
|
||||
}
|
||||
impl Event {
|
||||
pub fn is_source_back(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
)))
|
||||
)
|
||||
}
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(_))
|
||||
| Self::Scenario(scenario::Event::Source(_))
|
||||
| Self::Topology(topology::Event::Source(_))
|
||||
)
|
||||
}
|
||||
pub fn starts_form(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -872,6 +895,18 @@ impl State {
|
||||
|
||||
/// Clear the active form after its prepared command commits successfully.
|
||||
/// Rejected commands must leave the form available for repair and retry.
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.creation.source_active()
|
||||
|| self.scenario.source_active()
|
||||
|| self.topology.source_active()
|
||||
}
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
|
||||
}
|
||||
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.creation.acknowledge_commit();
|
||||
self.topology.acknowledge_commit();
|
||||
@@ -919,6 +954,40 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
if let Some(card) = app.behavior.creation.view(p) {
|
||||
return card.map(|event| Message::Behavior(Event::Creation(event)));
|
||||
}
|
||||
if let Some(owner) = app.behavior.pending_owner() {
|
||||
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
|
||||
let unavailable = p.layer_of(owner) != Some(app.layer)
|
||||
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|
||||
|| (app.behavior.topology.pending_owner().is_some()
|
||||
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
|
||||
if unavailable {
|
||||
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
|
||||
if let Some(name) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.pending_name()
|
||||
.or_else(|| app.behavior.topology.pending_name())
|
||||
{
|
||||
out =
|
||||
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
|
||||
}
|
||||
if let Some(child) = app.behavior.scenario.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
|
||||
} else if let Some(child) = app.behavior.topology.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
|
||||
} else {
|
||||
let cancel = if app.behavior.scenario.pending_owner().is_some() {
|
||||
Event::Scenario(scenario::Event::Cancel)
|
||||
} else if app.behavior.topology.pending_owner().is_some() {
|
||||
Event::Topology(topology::Event::Cancel)
|
||||
} else {
|
||||
Event::States(states::Event::Cancel)
|
||||
};
|
||||
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
|
||||
}
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ impl Picker {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
@@ -97,6 +98,11 @@ 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,
|
||||
@@ -125,6 +131,17 @@ 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()
|
||||
}
|
||||
@@ -174,6 +191,49 @@ 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;
|
||||
@@ -184,6 +244,8 @@ 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,
|
||||
@@ -332,7 +394,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
fn reference_label(p: &Project, id: Id) -> String {
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
@@ -340,6 +402,15 @@ 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());
|
||||
}
|
||||
@@ -475,6 +546,21 @@ 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(
|
||||
@@ -488,7 +574,7 @@ impl State {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Involve existing work after creation.",
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
@@ -531,7 +617,17 @@ 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")
|
||||
@@ -544,9 +640,9 @@ impl State {
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through existing work and exchanges",
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an existing participant",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
@@ -598,7 +694,9 @@ 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",
|
||||
};
|
||||
@@ -609,14 +707,17 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected {
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
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 => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
|
||||
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::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
First(Context, Tool),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
@@ -132,7 +133,8 @@ pub enum ScrollArea {
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(context, _, _)
|
||||
Self::First(context, _)
|
||||
| Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
@@ -162,6 +164,7 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -240,6 +243,13 @@ 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>,
|
||||
@@ -272,6 +282,28 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
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 {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
@@ -452,8 +484,16 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, p: &Project, 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) => {
|
||||
@@ -568,6 +608,61 @@ 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)?
|
||||
{
|
||||
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)?;
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -577,6 +672,9 @@ impl State {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
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,
|
||||
@@ -940,7 +1038,14 @@ 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 layer = p.elements[&d.owner].layer();
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1256,6 +1361,28 @@ 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());
|
||||
}
|
||||
@@ -1266,6 +1393,8 @@ 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()
|
||||
};
|
||||
|
||||
@@ -168,6 +168,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.wrap(),
|
||||
text(if pending {
|
||||
"Pending form · the highlighted sequence position is retained".to_owned()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
"Start with a lifeline representing existing or explicitly new work / participants."
|
||||
.into()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
|
||||
} else {
|
||||
let guidance = match tool {
|
||||
CanvasTool::Message(_) => {
|
||||
@@ -186,6 +191,32 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.size(11)
|
||||
]
|
||||
.spacing(6);
|
||||
if !pending {
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
header = header.push(
|
||||
row![
|
||||
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
|
||||
button("Add first lifeline").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
header = header.push(
|
||||
row![
|
||||
text("No occurrences yet").size(12),
|
||||
button("Add first message").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
|
||||
})),
|
||||
button("Add first action").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if limited {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
|
||||
@@ -295,272 +326,301 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead")
|
||||
.on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Lifeline {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label(
|
||||
"Create commits one new source and this lifeline together.",
|
||||
));
|
||||
}
|
||||
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
} else {
|
||||
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
|
||||
}
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
"Reply reuses the call exchange in reverse and retains its correlation.",
|
||||
));
|
||||
}
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
workspace_layout(header.into(), details, canvas, form_panel, pending)
|
||||
}
|
||||
@@ -649,6 +709,7 @@ fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
))
|
||||
// A new owner/document needs its first measurement even at the same size.
|
||||
.key((context.document, context.scenario))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
|
||||
@@ -342,7 +342,7 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport() {
|
||||
fn scenario_workspace_fit_reobserves_same_sized_owner_and_document_changes() {
|
||||
use super::super::direct_tests::{navigation_event, navigation_fixture};
|
||||
let (project, [a, b, _], [reference, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
@@ -358,13 +358,14 @@ fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewpor
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let first_viewport = app.behavior.scenario.presentation.viewport.unwrap();
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert!(app.behavior.scenario.presentation.scale(graph) < 1.);
|
||||
let (_, first) = geometry(&mut view, &mut tree, size);
|
||||
let measured = first.scrolls["scenario-canvas"].0.size();
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, Some(measured));
|
||||
|
||||
let accepted = app.document.snapshot();
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, b);
|
||||
assert!(app.behavior.scenario.presentation.viewport.is_none());
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, None);
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
@@ -373,38 +374,50 @@ fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewpor
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.viewport,
|
||||
Some(first_viewport)
|
||||
);
|
||||
let (_, second) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(second.scrolls["scenario-canvas"].0.size(), measured);
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, Some(measured));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
let fitted = app.behavior.scenario.presentation.scale(graph);
|
||||
assert!(graph.width * fitted <= first_viewport.width - 16.);
|
||||
assert!(graph.height * fitted <= first_viewport.height - 16.);
|
||||
|
||||
let prior_document = app.scenario_context().unwrap().document;
|
||||
app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(b));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_ne!(context.document, prior_document);
|
||||
assert_eq!(
|
||||
context.scenario, b,
|
||||
"semantic UUID is retained across documents"
|
||||
assert!(
|
||||
fitted < 1.
|
||||
&& graph.width * fitted <= measured.width - 16.
|
||||
&& graph.height * fitted <= measured.height - 16.
|
||||
);
|
||||
assert!(app.behavior.scenario.presentation.viewport.is_none());
|
||||
let accepted = app.document.snapshot();
|
||||
view = fixture_view(&app.behavior.scenario, context, false, 68.);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.viewport,
|
||||
Some(first_viewport)
|
||||
);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(app.behavior.scenario.presentation.scale(graph), fitted);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
|
||||
let document = app.scenario_context().unwrap().document;
|
||||
let mut replacement = App::with_project(project);
|
||||
let _ = replacement.update(Message::Select(b));
|
||||
let _ = replacement.update(Message::Page(crate::app::Page::Behavior));
|
||||
assert_ne!(replacement.scenario_context().unwrap().document, document);
|
||||
assert_eq!(replacement.behavior.scenario.presentation.viewport, None);
|
||||
let accepted = replacement.document.snapshot();
|
||||
view = fixture_view(
|
||||
&replacement.behavior.scenario,
|
||||
replacement.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut replacement);
|
||||
let (_, replaced) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(replaced.scrolls["scenario-canvas"].0.size(), measured);
|
||||
assert_eq!(
|
||||
replacement.behavior.scenario.presentation.viewport,
|
||||
Some(measured)
|
||||
);
|
||||
navigation_event(&mut replacement, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(
|
||||
replacement.behavior.scenario.presentation.scale(graph),
|
||||
fitted
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&accepted,
|
||||
&replacement.document.snapshot()
|
||||
));
|
||||
assert!(!replacement.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
//! Read-only lifecycle geometry. Operand screen order is never execution order.
|
||||
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
|
||||
pub(crate) struct Geometry<'a> {
|
||||
pub rows: &'a BTreeMap<Id, (Id, f32)>,
|
||||
pub scopes: &'a BTreeMap<Id, (f32, f32)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Span {
|
||||
pub start: f32,
|
||||
pub end: f32,
|
||||
}
|
||||
impl Span {
|
||||
fn valid(self) -> bool {
|
||||
self.start.is_finite() && self.end.is_finite() && self.start <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Life {
|
||||
Alive,
|
||||
Absent,
|
||||
Unresolved,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Interval {
|
||||
pub scope: Id,
|
||||
pub span: Span,
|
||||
pub life: Life,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MarkerKind {
|
||||
Birth,
|
||||
Death,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Marker {
|
||||
pub occurrence: Id,
|
||||
pub scope: Id,
|
||||
pub y: f32,
|
||||
pub kind: MarkerKind,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UncertaintyReason {
|
||||
Conditional,
|
||||
Repeated,
|
||||
Parallel,
|
||||
MissingGeometry,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Uncertainty {
|
||||
pub scope: Id,
|
||||
pub reason: UncertaintyReason,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct LaneLifetime {
|
||||
pub lifeline: Id,
|
||||
pub intervals: Vec<Interval>,
|
||||
pub markers: Vec<Marker>,
|
||||
pub uncertainties: Vec<Uncertainty>,
|
||||
}
|
||||
|
||||
/// Stable lane-ID order; source/model/history and caller-owned geometry are untouched.
|
||||
/// An opaque ScenarioUse is a local use, not a caller birth/death contract.
|
||||
pub(crate) fn project_lifetimes(
|
||||
project: &Project,
|
||||
scenario: Id,
|
||||
geometry: &Geometry<'_>,
|
||||
) -> Vec<LaneLifetime> {
|
||||
if project
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_none_or(|e| e.kind() != Kind::Scenario)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Lifeline && e.parent == Some(scenario))
|
||||
.map(|e| {
|
||||
let mut projection = Projection {
|
||||
project,
|
||||
geometry,
|
||||
lane: e.id,
|
||||
visiting: BTreeSet::new(),
|
||||
result: LaneLifetime {
|
||||
lifeline: e.id,
|
||||
intervals: vec![],
|
||||
markers: vec![],
|
||||
uncertainties: vec![],
|
||||
},
|
||||
};
|
||||
let initial = if projection.contains(scenario, Some(MarkerKind::Birth)) {
|
||||
Life::Unresolved
|
||||
} else {
|
||||
Life::Alive
|
||||
};
|
||||
projection.sequence(scenario, initial);
|
||||
projection.result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Projection<'a, 'g> {
|
||||
project: &'a Project,
|
||||
geometry: &'a Geometry<'g>,
|
||||
lane: Id,
|
||||
visiting: BTreeSet<Id>,
|
||||
result: LaneLifetime,
|
||||
}
|
||||
impl Projection<'_, '_> {
|
||||
fn body(&self, id: Id) -> Option<&OccurrenceBody> {
|
||||
match self.project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn event(&self, id: Id) -> Option<MarkerKind> {
|
||||
match self.body(id)? {
|
||||
OccurrenceBody::Exchange {
|
||||
receiver: Some(line),
|
||||
message_kind,
|
||||
..
|
||||
} if *line == self.lane => match message_kind {
|
||||
MessageKind::Create => Some(MarkerKind::Birth),
|
||||
MessageKind::Delete => Some(MarkerKind::Death),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn contains(&self, scope: Id, kind: Option<MarkerKind>) -> bool {
|
||||
// Local structural walk only: no guards, iteration simulation or reuse expansion.
|
||||
let mut pending = vec![scope];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.event(id)
|
||||
.is_some_and(|event| kind.is_none_or(|k| event == k))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(steps) = self.project.elements.get(&id).and_then(|e| e.steps()) {
|
||||
pending.extend(steps);
|
||||
}
|
||||
if let Some(OccurrenceBody::Fragment { branches, .. }) = self.body(id) {
|
||||
pending.extend(branches);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
fn span(&self, scope: Id) -> Option<Span> {
|
||||
let &(start, end) = self.geometry.scopes.get(&scope)?;
|
||||
let span = Span { start, end };
|
||||
span.valid().then_some(span)
|
||||
}
|
||||
fn uncertain(&mut self, scope: Id, reason: UncertaintyReason) {
|
||||
let value = Uncertainty { scope, reason };
|
||||
if !self.result.uncertainties.contains(&value) {
|
||||
self.result.uncertainties.push(value);
|
||||
}
|
||||
}
|
||||
fn interval(&mut self, scope: Id, start: f32, end: f32, life: Life) {
|
||||
if start < end && start.is_finite() && end.is_finite() {
|
||||
self.result.intervals.push(Interval {
|
||||
scope,
|
||||
span: Span { start, end },
|
||||
life,
|
||||
});
|
||||
}
|
||||
}
|
||||
fn sequence(&mut self, scope: Id, entry: Life) -> Life {
|
||||
let Some(span) = self.span(scope) else {
|
||||
self.uncertain(scope, UncertaintyReason::MissingGeometry);
|
||||
return Life::Unresolved;
|
||||
};
|
||||
if !self.visiting.insert(scope) {
|
||||
self.uncertain(scope, UncertaintyReason::MissingGeometry);
|
||||
return Life::Unresolved;
|
||||
}
|
||||
let steps = self
|
||||
.project
|
||||
.elements
|
||||
.get(&scope)
|
||||
.and_then(|e| e.steps())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// A creation directly in this sequence fixes its local birth boundary.
|
||||
// A creation only in a nested operand cannot fix its ancestor's entry.
|
||||
let mut life = if steps
|
||||
.iter()
|
||||
.any(|&id| self.event(id) == Some(MarkerKind::Birth))
|
||||
{
|
||||
Life::Absent
|
||||
} else {
|
||||
entry
|
||||
};
|
||||
let mut cursor = span.start;
|
||||
for id in steps {
|
||||
let event = self.event(id);
|
||||
let fragment = match self.body(id) {
|
||||
Some(OccurrenceBody::Fragment { operator, branches }) => {
|
||||
Some((*operator, branches.clone()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if event.is_none() && fragment.is_none() {
|
||||
continue;
|
||||
}
|
||||
let y = self
|
||||
.geometry
|
||||
.rows
|
||||
.get(&id)
|
||||
.filter(|(owner, y)| {
|
||||
*owner == scope && y.is_finite() && *y >= cursor && *y <= span.end
|
||||
})
|
||||
.map(|(_, y)| *y);
|
||||
let Some(y) = y else {
|
||||
self.uncertain(id, UncertaintyReason::MissingGeometry);
|
||||
life = Life::Unresolved;
|
||||
continue;
|
||||
};
|
||||
self.interval(scope, cursor, y, life);
|
||||
cursor = y;
|
||||
if let Some(kind) = event {
|
||||
self.result.markers.push(Marker {
|
||||
occurrence: id,
|
||||
scope,
|
||||
y,
|
||||
kind,
|
||||
});
|
||||
life = match kind {
|
||||
MarkerKind::Birth => Life::Alive,
|
||||
MarkerKind::Death => Life::Absent,
|
||||
};
|
||||
}
|
||||
if let Some((operator, branches)) = fragment {
|
||||
let entry = life;
|
||||
let mut missing = false;
|
||||
for branch in branches {
|
||||
let bounds = self
|
||||
.span(branch)
|
||||
.filter(|s| s.start >= cursor && s.end <= span.end);
|
||||
if let Some(bounds) = bounds {
|
||||
// Every sibling inherits the same entry, never its displayed predecessor's exit.
|
||||
self.sequence(branch, entry);
|
||||
cursor = bounds.end;
|
||||
} else {
|
||||
self.uncertain(branch, UncertaintyReason::MissingGeometry);
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
if self.contains(id, None) {
|
||||
let reason = match operator {
|
||||
FragmentOperator::Alternative | FragmentOperator::Optional => {
|
||||
UncertaintyReason::Conditional
|
||||
}
|
||||
FragmentOperator::Loop => UncertaintyReason::Repeated,
|
||||
FragmentOperator::Parallel => UncertaintyReason::Parallel,
|
||||
};
|
||||
self.uncertain(id, reason);
|
||||
life = Life::Unresolved;
|
||||
} else if missing {
|
||||
life = Life::Unresolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interval(scope, cursor, span.end, life);
|
||||
self.visiting.remove(&scope);
|
||||
life
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_lifecycle/tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,470 +0,0 @@
|
||||
use super::*;
|
||||
use oa_core::*;
|
||||
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owner: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
exchange: Id,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
scopes: BTreeMap<Id, (f32, f32)>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new(layer: Layer) -> Self {
|
||||
let mut p = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let a = p.add_element(kind, layer, "Request", None).unwrap();
|
||||
let b = p.add_element(kind, layer, "Provide", None).unwrap();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, layer, "Lifecycle", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Service",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
let line = |p: &mut Project, represented| {
|
||||
let id = p
|
||||
.add_element(Kind::Lifeline, layer, "Participant", Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
id
|
||||
};
|
||||
let a = line(&mut p, a);
|
||||
let b = line(&mut p, b);
|
||||
Self {
|
||||
p,
|
||||
owner,
|
||||
a,
|
||||
b,
|
||||
exchange,
|
||||
rows: BTreeMap::new(),
|
||||
scopes: [(owner, (0., 600.))].into(),
|
||||
}
|
||||
}
|
||||
fn occurrence(&mut self, owner: Id, y: f32, body: OccurrenceBody) -> Id {
|
||||
let mut e = self
|
||||
.p
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
self.p.elements[&owner].layer(),
|
||||
"Occurrence",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let id = e.id;
|
||||
*e.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
self.p.elements.insert(id, e);
|
||||
if let Some(steps) = self.p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
steps.push(id);
|
||||
} else if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = self.p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
self.rows.insert(id, (owner, y));
|
||||
id
|
||||
}
|
||||
fn message(&mut self, owner: Id, y: f32, kind: MessageKind) -> Id {
|
||||
self.occurrence(
|
||||
owner,
|
||||
y,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(self.exchange),
|
||||
sender: Some(self.a),
|
||||
receiver: Some(self.b),
|
||||
message_kind: kind,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn fragment(&mut self, owner: Id, y: f32, operator: FragmentOperator) -> Id {
|
||||
self.occurrence(
|
||||
owner,
|
||||
y,
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn branch(&mut self, fragment: Id, start: f32, end: f32) -> Id {
|
||||
let id = self.occurrence(
|
||||
fragment,
|
||||
start,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "condition".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
self.scopes.insert(id, (start, end));
|
||||
id
|
||||
}
|
||||
fn project(&self) -> Vec<LaneLifetime> {
|
||||
let errors: Vec<_> = validation::validate(&self.p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"fixture must be ordinarily admitted: {errors:?}"
|
||||
);
|
||||
let before = self.p.clone();
|
||||
let source = source_project::format_bundle(&self.p);
|
||||
let geometry = Geometry {
|
||||
rows: &self.rows,
|
||||
scopes: &self.scopes,
|
||||
};
|
||||
let result = project_lifetimes(&self.p, self.owner, &geometry);
|
||||
assert_eq!(result, project_lifetimes(&self.p, self.owner, &geometry));
|
||||
assert_eq!(self.p, before);
|
||||
assert_eq!(source_project::format_bundle(&self.p), source);
|
||||
assert!(
|
||||
result
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].lifeline < pair[1].lifeline)
|
||||
);
|
||||
result
|
||||
}
|
||||
fn lane(&self) -> LaneLifetime {
|
||||
self.project()
|
||||
.into_iter()
|
||||
.find(|lane| lane.lifeline == self.b)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn intervals(lane: &LaneLifetime, scope: Id) -> Vec<(f32, f32, Life)> {
|
||||
lane.intervals
|
||||
.iter()
|
||||
.filter(|i| i.scope == scope)
|
||||
.map(|i| (i.span.start, i.span.end, i.life))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_lifetime_has_exact_birth_death_boundaries_without_source_mutation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
f.message(f.owner, 200., MessageKind::Asynchronous);
|
||||
let delete = f.message(f.owner, 300., MessageKind::Delete);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![
|
||||
(0., 100., Life::Absent),
|
||||
(100., 300., Life::Alive),
|
||||
(300., 600., Life::Absent)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lane.markers,
|
||||
vec![
|
||||
Marker {
|
||||
occurrence: create,
|
||||
scope: f.owner,
|
||||
y: 100.,
|
||||
kind: MarkerKind::Birth
|
||||
},
|
||||
Marker {
|
||||
occurrence: delete,
|
||||
scope: f.owner,
|
||||
y: 300.,
|
||||
kind: MarkerKind::Death
|
||||
},
|
||||
]
|
||||
);
|
||||
let sender = f
|
||||
.project()
|
||||
.into_iter()
|
||||
.find(|lane| lane.lifeline == f.a)
|
||||
.unwrap();
|
||||
assert_eq!(intervals(&sender, f.owner), vec![(0., 600., Life::Alive)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternative_death_does_not_kill_its_displayed_sibling() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Delete);
|
||||
f.message(right, 350., MessageKind::Asynchronous);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, left),
|
||||
vec![(120., 200., Life::Alive), (200., 250., Life::Absent)]
|
||||
);
|
||||
assert_eq!(intervals(&lane, right), vec![(300., 450., Life::Alive)]);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 100., Life::Alive), (450., 600., Life::Unresolved)]
|
||||
);
|
||||
assert_eq!(
|
||||
lane.uncertainties,
|
||||
vec![Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Conditional
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_birth_does_not_infer_absence_in_siblings_or_ancestors() {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Optional] {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let fragment = f.fragment(f.owner, 100., operator);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Create);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, left),
|
||||
vec![(120., 200., Life::Absent), (200., 250., Life::Alive)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, right),
|
||||
vec![(300., 450., Life::Unresolved)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 100., Life::Unresolved), (450., 600., Life::Unresolved)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_lifetime_is_local_and_warning_bearing_loops_remain_drawable() {
|
||||
for balanced in [true, false] {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Loop);
|
||||
let iteration = f.branch(fragment, 120., 450.);
|
||||
let create = f.message(iteration, 160., MessageKind::Create);
|
||||
f.message(iteration, 250., MessageKind::Asynchronous);
|
||||
if balanced {
|
||||
f.message(iteration, 350., MessageKind::Delete);
|
||||
}
|
||||
let lane = f.lane();
|
||||
assert_eq!(lane.markers.len(), if balanced { 2 } else { 1 });
|
||||
assert_eq!(lane.markers[0].occurrence, create);
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Repeated
|
||||
}));
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner).last().unwrap().2,
|
||||
Life::Unresolved
|
||||
);
|
||||
assert_eq!(
|
||||
validation::validate(&f.p)
|
||||
.iter()
|
||||
.any(|i| i.code == "lifeline_repetition"),
|
||||
!balanced
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_cleanup_inside_loop_does_not_claim_balanced_or_definite_exit() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let repeated = f.fragment(f.owner, 100., FragmentOperator::Loop);
|
||||
let iteration = f.branch(repeated, 120., 500.);
|
||||
f.message(iteration, 160., MessageKind::Create);
|
||||
let optional = f.fragment(iteration, 280., FragmentOperator::Optional);
|
||||
let cleanup = f.branch(optional, 320., 400.);
|
||||
f.message(cleanup, 350., MessageKind::Delete);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, cleanup),
|
||||
vec![(320., 350., Life::Alive), (350., 400., Life::Absent)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, iteration),
|
||||
vec![
|
||||
(120., 160., Life::Absent),
|
||||
(160., 280., Life::Alive),
|
||||
(400., 500., Life::Unresolved)
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&f.p)
|
||||
.iter()
|
||||
.any(|i| i.code == "lifeline_repetition")
|
||||
);
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: optional,
|
||||
reason: UncertaintyReason::Conditional
|
||||
}));
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: repeated,
|
||||
reason: UncertaintyReason::Repeated
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_free_fragments_do_not_poison_other_lanes() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Parallel);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Delete);
|
||||
let result = f.project();
|
||||
let sender = result.iter().find(|lane| lane.lifeline == f.a).unwrap();
|
||||
assert!(sender.intervals.iter().all(|i| i.life == Life::Alive));
|
||||
assert!(sender.uncertainties.is_empty());
|
||||
let target = result.iter().find(|lane| lane.lifeline == f.b).unwrap();
|
||||
assert!(target.uncertainties.contains(&Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Parallel
|
||||
}));
|
||||
assert_eq!(intervals(target, right), vec![(300., 450., Life::Alive)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_receiver_is_authoritative_even_with_missing_exchange() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
}
|
||||
assert_eq!(f.lane().markers[0].occurrence, create);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
receiver, exchange, ..
|
||||
},
|
||||
..
|
||||
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*receiver = None;
|
||||
*exchange = Some(f.exchange);
|
||||
}
|
||||
assert!(
|
||||
f.lane().markers.is_empty(),
|
||||
"exchange endpoints never invent a receiver"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_geometry_is_reported_without_nonfinite_or_guessed_markers() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
f.rows.insert(create, (f.owner, f32::NAN));
|
||||
let lane = f.lane();
|
||||
assert!(lane.markers.is_empty());
|
||||
assert_eq!(
|
||||
lane.uncertainties,
|
||||
vec![Uncertainty {
|
||||
scope: create,
|
||||
reason: UncertaintyReason::MissingGeometry
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 600., Life::Unresolved)]
|
||||
);
|
||||
f.scopes.remove(&f.owner);
|
||||
let lane = f.lane();
|
||||
assert!(lane.intervals.is_empty());
|
||||
assert_eq!(lane.uncertainties[0].scope, f.owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_use_is_opaque_and_never_imports_callee_lifecycle_events() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let callee =
|
||||
f.p.add_element(Kind::Scenario, Layer::Oa, "Callee", None)
|
||||
.unwrap();
|
||||
let remote_a =
|
||||
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote A", Some(callee))
|
||||
.unwrap();
|
||||
let remote_b =
|
||||
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote B", Some(callee))
|
||||
.unwrap();
|
||||
for (remote, local) in [(remote_a, f.a), (remote_b, f.b)] {
|
||||
let body = f.p.elements[&local].shared().unwrap().clone();
|
||||
*f.p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
f.occurrence(
|
||||
callee,
|
||||
200.,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: Some(remote_a),
|
||||
receiver: Some(remote_b),
|
||||
message_kind: MessageKind::Create,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.occurrence(
|
||||
f.owner,
|
||||
100.,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(callee),
|
||||
bindings: vec![
|
||||
LifelineBinding {
|
||||
referenced: remote_a,
|
||||
local: f.a,
|
||||
},
|
||||
LifelineBinding {
|
||||
referenced: remote_b,
|
||||
local: f.b,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
let lane = f.lane();
|
||||
assert_eq!(intervals(&lane, f.owner), vec![(0., 600., Life::Alive)]);
|
||||
assert!(lane.markers.is_empty());
|
||||
assert!(lane.uncertainties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_retains_exact_authored_source_and_does_not_enter_history() {
|
||||
use crate::document::Document;
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
f.message(f.owner, 100., MessageKind::Create);
|
||||
f.message(f.owner, 300., MessageKind::Delete);
|
||||
let mut source = source_project::format_bundle(&f.p);
|
||||
let text = source.files.get_mut(&source.entry).unwrap();
|
||||
*text = format!("// Retained author note — 雨\r\n{text}\n");
|
||||
let document =
|
||||
Document::from_bundle(source.clone(), source_project::ProjectLimits::default()).unwrap();
|
||||
let accepted = document.project().clone();
|
||||
let geometry = Geometry {
|
||||
rows: &f.rows,
|
||||
scopes: &f.scopes,
|
||||
};
|
||||
let result = project_lifetimes(document.project(), f.owner, &geometry);
|
||||
assert_eq!(result, f.project());
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert_eq!(document.project(), &accepted);
|
||||
assert!(!document.can_undo());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ impl Tool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool, Option<Id>),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -90,15 +91,22 @@ pub struct State {
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[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)>,
|
||||
|
||||
#[serde(with = "recovery_insertion")]
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
@@ -117,6 +125,28 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
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 {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
@@ -152,6 +182,58 @@ 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)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Work {
|
||||
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::Work { owner: d.owner },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -184,6 +266,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -508,6 +593,22 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
||||
}
|
||||
impl Draft {
|
||||
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::Work { owner: 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> {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&self.owner)
|
||||
@@ -615,6 +716,8 @@ impl Draft {
|
||||
});
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default_name
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -1587,6 +1690,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: None,
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Event {
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
|
||||
@@ -206,8 +206,16 @@ impl State {
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
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;
|
||||
}
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
|
||||
@@ -369,6 +369,25 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) {
|
||||
let title = if p.layer_of(owner) == Some(Layer::Oa) {
|
||||
"Add first activity contribution"
|
||||
} else {
|
||||
"Add first function contribution"
|
||||
};
|
||||
header = header.push(button(title).on_press_maybe(
|
||||
(!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then(
|
||||
|| {
|
||||
Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
})
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
@@ -383,201 +402,223 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Work {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
||||
}
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ use std::{fmt, sync::Arc};
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[path = "document_alias_rename.rs"]
|
||||
mod alias_rename;
|
||||
pub use alias_rename::PreparedAliasRename;
|
||||
|
||||
struct HistoryEntry {
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
bytes: usize,
|
||||
@@ -91,11 +95,13 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
// Distinguishes equal accepted snapshots belonging to different documents.
|
||||
alias_owner: Arc<()>,
|
||||
current: Arc<CompiledSourceProject>,
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Arc<SourceBundle>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -121,11 +127,12 @@ impl Document {
|
||||
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
||||
let current_bytes = estimated_bytes(¤t);
|
||||
Ok(Self {
|
||||
alias_owner: Arc::new(()),
|
||||
current,
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Arc::new(bundle),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -156,7 +163,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -181,10 +188,24 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
@@ -380,8 +401,17 @@ impl Document {
|
||||
return false;
|
||||
}
|
||||
let bytes = estimated_bytes(&compiled);
|
||||
self.commit_snapshot(Arc::new(compiled), bytes)
|
||||
}
|
||||
|
||||
// Only validated internal paths supply a changed snapshot and its exact
|
||||
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
||||
// path checks its private changed flag and exact accepted-snapshot authority.
|
||||
// Alias preparation shares this algorithm while retaining the live save and
|
||||
// history state, and does not clone/recompile the candidate on adoption.
|
||||
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
||||
self.past.push(HistoryEntry {
|
||||
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
|
||||
snapshot: std::mem::replace(&mut self.current, compiled),
|
||||
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
||||
});
|
||||
self.future.clear();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
|
||||
//! not authorize adoption; only this module can mint a prepared rename.
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A pinned intent minted by its owning Document. Move it to a worker and call
|
||||
/// `prepare`; preparation has no access to the live Document or its histories.
|
||||
#[derive(Debug)]
|
||||
pub struct AliasRenameRequest {
|
||||
owner: Arc<()>,
|
||||
before: Arc<CompiledSourceProject>,
|
||||
revision: u64,
|
||||
limits: ProjectLimits,
|
||||
target: Id,
|
||||
old_alias: String,
|
||||
new_alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedData {
|
||||
request: AliasRenameRequest,
|
||||
after: Arc<CompiledSourceProject>,
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Immutable, Clone-compatible transport for preview and approval. Neither
|
||||
/// mutable candidate access nor deserialization can manufacture this authority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedAliasRename(Arc<PreparedData>);
|
||||
|
||||
impl PreparedAliasRename {
|
||||
pub fn before(&self) -> &CompiledSourceProject {
|
||||
&self.0.request.before
|
||||
}
|
||||
|
||||
pub fn after(&self) -> &CompiledSourceProject {
|
||||
&self.0.after
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Id {
|
||||
self.0.request.target
|
||||
}
|
||||
|
||||
pub fn old_alias(&self) -> &str {
|
||||
&self.0.request.old_alias
|
||||
}
|
||||
|
||||
pub fn new_alias(&self) -> &str {
|
||||
&self.0.request.new_alias
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.0.request.revision
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.0.changed
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(message: &str) -> DocumentError {
|
||||
ModelError(message.into()).into()
|
||||
}
|
||||
|
||||
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
|
||||
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn alias_rename_request(
|
||||
&self,
|
||||
target: Id,
|
||||
replacement: String,
|
||||
) -> Result<AliasRenameRequest, DocumentError> {
|
||||
if self.revision == u64::MAX {
|
||||
return Err(refusal(
|
||||
"The document revision is exhausted. Reopen before renaming.",
|
||||
));
|
||||
}
|
||||
let old_alias = self
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?;
|
||||
// A candidate has a nonzero estimate. Avoid expensive preparation when
|
||||
// even the current snapshot leaves no room for its immediate Undo.
|
||||
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(AliasRenameRequest {
|
||||
owner: self.alias_owner.clone(),
|
||||
before: self.current.clone(),
|
||||
revision: self.revision,
|
||||
limits: self.limits,
|
||||
target,
|
||||
old_alias,
|
||||
new_alias: replacement,
|
||||
})
|
||||
}
|
||||
|
||||
/// App must separately consume its current approval and check workspace,
|
||||
/// invocation and draft freshness. This checks accepted Document authority.
|
||||
pub fn commit_alias_rename(
|
||||
&mut self,
|
||||
prepared: &PreparedAliasRename,
|
||||
) -> Result<bool, DocumentError> {
|
||||
let data = &prepared.0;
|
||||
let request = &data.request;
|
||||
if self.revision == u64::MAX
|
||||
|| self.revision != request.revision
|
||||
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|
||||
|| !Arc::ptr_eq(&self.current, &request.before)
|
||||
{
|
||||
return Err(refusal(
|
||||
"This rename preview is stale. Preview the current alias again.",
|
||||
));
|
||||
}
|
||||
if !data.changed {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.current_bytes != data.before_bytes {
|
||||
return Err(refusal(
|
||||
"The rename history estimate is stale. Preview again.",
|
||||
));
|
||||
}
|
||||
check_history_capacity(self.current_bytes, data.after_bytes)?;
|
||||
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasRenameRequest {
|
||||
/// Run on the preparation worker. The typed command and source updater are
|
||||
/// the only candidate construction path, followed by alias-only invariants.
|
||||
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
|
||||
let before_project = &self.before.decoded.project;
|
||||
let candidate = prepare_command(
|
||||
before_project,
|
||||
&Command::SetAlias(self.target, self.new_alias.clone()),
|
||||
)?;
|
||||
let mut expected = before_project.clone();
|
||||
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
|
||||
let identities: BTreeMap<_, _> = before_project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
if candidate.project != expected || candidate.identity_map != identities {
|
||||
return Err(refusal(
|
||||
"Alias preparation changed unrelated model data or identities.",
|
||||
));
|
||||
}
|
||||
let after = source_project::update_bundle_with_identity_map(
|
||||
&self.before,
|
||||
&candidate.project,
|
||||
&candidate.identity_map,
|
||||
self.limits,
|
||||
)?;
|
||||
let before = &self.before;
|
||||
if after.decoded.project != expected
|
||||
|| before.bundle.entry != after.bundle.entry
|
||||
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|
||||
|| before.index.lexical_owners != after.index.lexical_owners
|
||||
|| !before
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path))
|
||||
.eq(after
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path)))
|
||||
{
|
||||
return Err(refusal(
|
||||
"Alias preparation changed source membership or ownership.",
|
||||
));
|
||||
}
|
||||
let changed = before.bundle != after.bundle;
|
||||
let before_bytes = estimated_bytes(before);
|
||||
let after_bytes = estimated_bytes(&after);
|
||||
if changed {
|
||||
check_history_capacity(before_bytes, after_bytes)?;
|
||||
}
|
||||
Ok(PreparedAliasRename(Arc::new(PreparedData {
|
||||
request: self,
|
||||
after: Arc::new(after),
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
changed,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// Independent full-model expectation: SetAlias may change exactly one header
|
||||
// field, across every supported identity family. Do not reuse command execution
|
||||
// to derive the expected result, which would hide collateral command changes.
|
||||
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
|
||||
if project.id == id {
|
||||
project.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.elements.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.relations.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.diagrams.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.artifact_links.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.traces.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else {
|
||||
project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.find_map(|diagram| diagram.nodes.get_mut(&id))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?
|
||||
.alias = alias.to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "document_alias_rename_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Document {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), r#"arcadia 7;
|
||||
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
||||
include "work.arcadia"; include "views.arcadia";
|
||||
}
|
||||
"#.replace('\n', "\r\n")),
|
||||
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
||||
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
||||
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
||||
assess; }
|
||||
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
||||
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
||||
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
||||
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
||||
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
||||
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no migration or ID insertion"
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
||||
document
|
||||
.alias_rename_request(target, replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
||||
// catches replacing an equal snapshot or saved marker behind the user's back.
|
||||
struct Unchanged {
|
||||
current: Arc<CompiledSourceProject>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
revision: u64,
|
||||
current_bytes: usize,
|
||||
recovered: bool,
|
||||
}
|
||||
|
||||
impl Unchanged {
|
||||
fn capture(document: &Document) -> Self {
|
||||
Self {
|
||||
current: document.snapshot(),
|
||||
saved: document.saved.clone(),
|
||||
past: document
|
||||
.past
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
future: document
|
||||
.future
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
revision: document.revision,
|
||||
current_bytes: document.current_bytes,
|
||||
recovered: document.recovered_unsaved,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert(&self, document: &Document) {
|
||||
assert!(Arc::ptr_eq(&self.current, &document.current));
|
||||
match (&self.saved, &document.saved) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("saved baseline changed"),
|
||||
}
|
||||
for (before, after) in [
|
||||
(&self.past, &document.past),
|
||||
(&self.future, &document.future),
|
||||
] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
||||
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
||||
assert_eq!(*bytes, entry.bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(self.revision, document.revision);
|
||||
assert_eq!(self.current_bytes, document.current_bytes);
|
||||
assert_eq!(self.recovered, document.recovered_unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
||||
// Literal role-specific replacements are independent of the updater/tokenizer.
|
||||
// They deliberately exclude prose, historical review text and URI spelling.
|
||||
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
||||
let cases: &[AliasCase<'_>] = &[
|
||||
(
|
||||
1,
|
||||
"mission",
|
||||
"renamed",
|
||||
&[("project mission ", "project renamed ")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
"assess",
|
||||
"renamed",
|
||||
&[
|
||||
("oa_activity assess ", "oa_activity renamed "),
|
||||
("\r\n assess;", "\r\n renamed;"),
|
||||
("@assess;", "@renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transfer",
|
||||
"renamed",
|
||||
&[
|
||||
("interaction transfer ", "interaction renamed "),
|
||||
("\r\n transfer;", "\r\n renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
6,
|
||||
"review",
|
||||
"renamed",
|
||||
&[
|
||||
("trace review ", "trace renamed "),
|
||||
("owner @review;", "owner @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
7,
|
||||
"evidence",
|
||||
"renamed",
|
||||
&[("artifact evidence ", "artifact renamed ")],
|
||||
),
|
||||
(
|
||||
8,
|
||||
"overview",
|
||||
"renamed",
|
||||
&[
|
||||
("diagram overview ", "diagram renamed "),
|
||||
("diagram @overview;", "diagram @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"placement",
|
||||
"renamed",
|
||||
&[("node placement ", "node renamed ")],
|
||||
),
|
||||
];
|
||||
for &(number, old, new, replacements) in cases {
|
||||
let mut document = fixture();
|
||||
let original = document.snapshot();
|
||||
let mut expected_model = document.project().clone();
|
||||
// Explicit per-family oracle, separate from production expectation helper.
|
||||
match number {
|
||||
1 => expected_model.alias = new.into(),
|
||||
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
||||
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
||||
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
||||
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
||||
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
||||
9 => {
|
||||
expected_model
|
||||
.diagrams
|
||||
.get_mut(&id(8))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(9))
|
||||
.unwrap()
|
||||
.alias = new.into()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut expected_bundle = document.bundle().clone();
|
||||
for source in expected_bundle.files.values_mut() {
|
||||
for &(before, after) in replacements {
|
||||
*source = source.replace(before, after);
|
||||
}
|
||||
}
|
||||
let state = Unchanged::capture(&document);
|
||||
let prepared = prepare(&document, id(number), new);
|
||||
state.assert(&document);
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.old_alias(), old);
|
||||
assert_eq!(prepared.new_alias(), new);
|
||||
assert_eq!(prepared.target(), id(number));
|
||||
assert_eq!(prepared.revision(), 0);
|
||||
assert_eq!(prepared.before().bundle, original.bundle);
|
||||
assert_eq!(prepared.after().bundle, expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, expected_model);
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert!(
|
||||
Arc::ptr_eq(&document.current, &prepared.0.after),
|
||||
"adopt the exact prepared Arc"
|
||||
);
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.dirty());
|
||||
let after = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
||||
after.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &original));
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &expected_bundle);
|
||||
assert_eq!(document.project(), &expected_model);
|
||||
document.mark_saved(document.snapshot());
|
||||
let reopened =
|
||||
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected_model);
|
||||
assert_eq!(reopened.bundle(), &expected_bundle);
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
||||
assert!(matches!(
|
||||
language::tokenize("oa_activity")[0].class,
|
||||
language::TokenClass::Keyword
|
||||
));
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "oa_activity");
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
||||
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
||||
let mut document = fixture();
|
||||
let first = prepare(&document, id(2), "changed");
|
||||
document.commit_alias_rename(&first).unwrap();
|
||||
assert!(document.undo());
|
||||
document.invalidate_saved_baseline();
|
||||
document.mark_recovered();
|
||||
let state = Unchanged::capture(&document);
|
||||
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
state.assert(&document);
|
||||
}
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(Id::nil(), "new".into())
|
||||
.is_err()
|
||||
);
|
||||
let no_op = prepare(&document, id(2), "assess");
|
||||
assert!(!no_op.changed());
|
||||
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
||||
state.assert(&document);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
||||
let mut document = fixture();
|
||||
let old = prepare(&document, id(2), "renamed");
|
||||
let mut second = fixture();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
||||
second.current = document.current.clone();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Later name".into()))
|
||||
.unwrap();
|
||||
let edited = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
edited.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
||||
let undone = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
undone.assert(&document);
|
||||
document.revision = u64::MAX - 1;
|
||||
let last = prepare(&document, id(2), "renamed");
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
||||
.unwrap();
|
||||
document.undo();
|
||||
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
||||
let exhausted = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&last).is_err());
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_err()
|
||||
);
|
||||
exhausted.assert(&document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
||||
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let original = document.snapshot();
|
||||
match baseline {
|
||||
"saved_before" => document.mark_saved(original.clone()),
|
||||
"unknown" => document.invalidate_saved_baseline(),
|
||||
"raw_repair" => {
|
||||
let mut raw = document.bundle().clone();
|
||||
raw.files
|
||||
.insert("project.arcadia".into(), "invalid repair source".into());
|
||||
document.bind_repair_baseline(raw);
|
||||
}
|
||||
"recovered" => document.mark_recovered(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let saved = document.saved.clone();
|
||||
let recovered = document.recovered_unsaved;
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert!(document.dirty());
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert_eq!(document.dirty(), baseline != "saved_before");
|
||||
}
|
||||
// A completed save after Preview remains authoritative, including when it
|
||||
// clears recovery; a worker must not restore its earlier state on Confirm.
|
||||
let mut document = fixture();
|
||||
document.mark_recovered();
|
||||
document.invalidate_saved_baseline();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
document.mark_saved(document.snapshot());
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert!(!document.recovered_unsaved);
|
||||
document.undo();
|
||||
assert!(!document.dirty());
|
||||
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
||||
document.redo();
|
||||
document.mark_saved(document.snapshot());
|
||||
document.undo();
|
||||
let return_to_saved = prepare(&document, id(2), "renamed");
|
||||
document.commit_alias_rename(&return_to_saved).unwrap();
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let exact = prepared.after().clone();
|
||||
let mut counterfeit = prepared.after().clone();
|
||||
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
||||
counterfeit.index.declarations.clear();
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(document.apply_compiled(counterfeit).is_err());
|
||||
unchanged.assert(&document);
|
||||
let mut unrelated = exact.decoded.project.clone();
|
||||
unrelated.name = "A self-consistent unrelated candidate".into();
|
||||
let forged_dto = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&unrelated),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
||||
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
||||
// leaves the opaque capability and its exact adopted model/source intact.
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project(), &exact.decoded.project);
|
||||
assert_eq!(document.bundle(), &exact.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
||||
let mut document = fixture();
|
||||
let mut bundle = document.bundle().clone();
|
||||
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
||||
bundle
|
||||
.files
|
||||
.get_mut("views.arcadia")
|
||||
.unwrap()
|
||||
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
||||
document.apply_bundle(bundle).unwrap();
|
||||
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
||||
// A same-length alias retains the exact estimate; older history is evictable.
|
||||
let boundary = prepare(&document, id(2), "rating");
|
||||
assert_eq!(
|
||||
boundary.0.before_bytes + boundary.0.after_bytes,
|
||||
HISTORY_BYTES
|
||||
);
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "longer_alias".into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
document.commit_alias_rename(&boundary).unwrap();
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
||||
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
||||
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
// Inject cache corruption to falsify the commit-side check independently of
|
||||
// the preparation admission. Normal edits also change revision and Arc.
|
||||
document.current_bytes = HISTORY_BYTES;
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(document.commit_alias_rename(&prepared).is_err());
|
||||
// No-op intent needs no Undo reservation even at the admission boundary.
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_ok()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user