Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f98a9bf5b
|
||
|
|
9eb1d10b28
|
||
|
|
3fc0d7c57d
|
No files matched your search
+110
@@ -4,6 +4,116 @@ All notable changes to this workspace are documented here. The format
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## 0.5.1 — 2026-04-29
|
||||
|
||||
Docs and licensing cleanup. No code or behavior changes.
|
||||
|
||||
- **License simplified to MIT.** The crate was previously dual-licensed
|
||||
MIT OR Apache-2.0; it is now MIT-only. `LICENSE-APACHE` removed,
|
||||
`LICENSE-MIT` renamed to `LICENSE`, workspace `license` field is
|
||||
now `"MIT"`. Copyright (c) 2026 Stephen Waits <steve@waits.net>.
|
||||
- **README rewritten reader-first.** Restructured top-to-bottom so a
|
||||
reader who's never used statecharts can follow the document linearly:
|
||||
a working Blinker chart is now the first concrete artifact; the
|
||||
Quickstart sits directly under it; the larger Radio example
|
||||
(events / payloads / `during:`) follows; the formal eight-rule
|
||||
semantics live below all of that as reference rather than as a
|
||||
prerequisite. The "What it does" feature list is rewritten in
|
||||
user-benefit-first language. The "What `hsmc` deliberately does NOT
|
||||
have" table is promoted from a sub-section to its own top-level
|
||||
section. No semantic changes — the formal rules' content is
|
||||
untouched, only repositioned.
|
||||
- **Tokio integration story corrected.** The README previously implied
|
||||
driving a chart under tokio required `tokio::task::LocalSet`. The
|
||||
normal path is `#[tokio::main(flavor = "current_thread")]` — one
|
||||
line, no extra wrapping, and how the existing examples have always
|
||||
worked. `LocalSet` no longer appears in the README at all.
|
||||
- **`microwave_tui` example dropped.** The ratatui/crossterm TUI
|
||||
variant of the microwave was overkill for a library example and
|
||||
pulled in three dev-dependencies (`ratatui`, `crossterm`, `futures`)
|
||||
that nothing else used. The `microwave` example covers the same
|
||||
feature surface in plain Rust.
|
||||
- **Observability framed honestly.** Enabling `defmt` / `log` /
|
||||
`tracing` output for a chart is one `trace-*` Cargo feature on
|
||||
hsmc's side, plus whatever your downstream crate already does to
|
||||
init its logger (e.g. `tracing_subscriber::fmt::init()` or
|
||||
`env_logger::init()`) — hsmc deliberately doesn't init a logger,
|
||||
per the standard Rust library convention. The Observability
|
||||
section now says so directly and includes a runnable end-to-end
|
||||
snippet using `tracing-subscriber`. The `trace-*` features are
|
||||
also listed in the main feature-flags table so they're
|
||||
discoverable at a glance.
|
||||
- **Inline examples corrected.** The Quickstart Blinker now has both
|
||||
`light_on` and `light_off` actions (it previously had only
|
||||
`light_on`, which left the LED on the whole time). The Embassy
|
||||
snippet no longer declares an unused event channel for what is a
|
||||
timer-only chart.
|
||||
- **Eight-rule order finalized.** The "How a chart behaves" rules are
|
||||
now strictly bottom-up: active path → transitions (LCA-aware) →
|
||||
up-transition rule → entry/exit ordering → `default(...)` (a
|
||||
transition fired on entry) → event bubbling → timers →
|
||||
emit/during/termination. Cross-references updated.
|
||||
|
||||
## 0.5.0 — 2026-04-28
|
||||
|
||||
### `default(...)` is a real LCA-aware transition
|
||||
|
||||
`default(...)` is now modeled the same way as any event-driven
|
||||
transition. It fires immediately after the declaring state's entries
|
||||
finish and its durings start, applying the standard exit-to-LCA /
|
||||
enter-to-target algorithm. Three behavioral changes follow from this:
|
||||
|
||||
- **Optional.** A state with children no longer must declare a
|
||||
`default(...)`. When omitted, the state itself is the resting
|
||||
innermost active state; substates are reachable only via explicit
|
||||
transitions. This is the "microwave Standby" pattern — `Standby`
|
||||
has substates like `LowPower` you enter only on demand, and
|
||||
`=> Standby;` lands at `Standby` itself with no descent.
|
||||
- **Targets anywhere.** The default target may be any state in the
|
||||
chart — a child (the classic descent case), a sibling, an ancestor,
|
||||
anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
|
||||
state as part of the default-fire transition; the same uniform
|
||||
rule covers descent, redirection, and up-bounce.
|
||||
- **Cycle-checked at compile time.** The default-edge graph
|
||||
(out-degree ≤ 1 per node) is rejected if it contains any cycle,
|
||||
including `default(self)`. The check guarantees default chains
|
||||
terminate at runtime — entering any state in a cycle would loop
|
||||
forever before reaching user code.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **Journal format.** Default-fires now emit `TransitionFired` (with
|
||||
`reason: TransitionReason::Internal`) and `TransitionComplete`
|
||||
brackets around their entry sequence. `Internal` was previously
|
||||
reserved and never emitted; it is now active. Charts that
|
||||
hand-build expected journal sequences (replay-style tests) must
|
||||
include these brackets around every default-fire — see the
|
||||
per-test diffs in this release for the pattern.
|
||||
- **Validation.** Two compile-time errors went away:
|
||||
`default(<non-direct-child>)` is now valid (the target may be
|
||||
anywhere), and `default` on a childless state is now valid
|
||||
(the leaf transitions out immediately on entry). Code relying
|
||||
on these as guard rails must adapt. A new compile-time error
|
||||
takes their place: any cycle in the default graph is rejected
|
||||
with a pinpoint message naming the cycle (e.g.
|
||||
`default-transition cycle detected: A -> B -> A`).
|
||||
|
||||
### Other
|
||||
|
||||
- README and `docs/002. hsmc-semantics-formal.md` rewritten so the
|
||||
default-as-transition framing is the primary description; the old
|
||||
"automatic transition into a child" language is gone.
|
||||
- New integration tests covering each branch of the new semantics:
|
||||
`det_optional_default` (microwave pattern), `det_default_sibling`
|
||||
(lateral default), `det_default_chained` (multi-link default
|
||||
chain), `det_default_ancestor` (up-default landing on an
|
||||
ancestor).
|
||||
- New compile-fail UI fixtures for cycle detection
|
||||
(`t9_4_default_self_cycle`, `t9_5_default_two_cycle`) and new
|
||||
compile-pass fixtures for non-child default targets
|
||||
(`composite_without_default`, `default_targets_sibling`,
|
||||
`default_targets_ancestor`).
|
||||
|
||||
## 0.4.1 — 2026-04-28
|
||||
|
||||
### Performance — dispatch hot path
|
||||
|
||||
Generated
+6
-595
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -29,53 +23,18 @@ version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "creusot-std"
|
||||
version = "0.9.0"
|
||||
@@ -103,66 +62,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
@@ -178,7 +77,7 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
@@ -234,12 +133,6 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embassy-futures"
|
||||
version = "0.1.2"
|
||||
@@ -331,110 +224,18 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -450,17 +251,6 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
@@ -487,27 +277,18 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.4.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"defmt 0.3.100",
|
||||
"embassy-futures",
|
||||
"embassy-sync",
|
||||
"embassy-time",
|
||||
"futures",
|
||||
"heapless 0.8.0",
|
||||
"hsmc-macros",
|
||||
"iai-callgrind",
|
||||
"log",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"trybuild",
|
||||
@@ -515,7 +296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.4.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"hsmc",
|
||||
"proc-macro2",
|
||||
@@ -526,7 +307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-verification"
|
||||
version = "0.4.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"creusot-std",
|
||||
"hsmc",
|
||||
@@ -568,12 +349,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -581,38 +356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -621,66 +365,24 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "0.1.3"
|
||||
@@ -735,35 +437,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -810,36 +483,6 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -849,37 +492,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -938,89 +550,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -1164,87 +699,27 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "void"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1254,70 +729,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@ resolver = "2"
|
||||
members = ["hsmc", "hsmc-macros", "verification"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.1"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "MIT"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
repository = "https://github.com/swaits/hsmc"
|
||||
homepage = "https://github.com/swaits/hsmc"
|
||||
File renamed without changes.
-201
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
@@ -1,10 +1,98 @@
|
||||
# hsmc — Hierarchical State Machines for Rust
|
||||
|
||||
`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets
|
||||
embedded Rust (embassy) as the primary runtime and supports `tokio` via
|
||||
`LocalSet`. Machines are compiled from a `statechart!` block into
|
||||
monomorphized code with no dynamic dispatch, no interior mutability, and no
|
||||
heap allocations by default.
|
||||
`hsmc` lets you write the control logic of a device — a microwave, a
|
||||
LoRa radio, a sensor, a UI flow — as a **statechart**: nested states
|
||||
with entry/exit actions, transitions, timers, and async work, all
|
||||
declared in one block. The proc macro turns it into Rust code with no
|
||||
heap allocations, no dynamic dispatch, and no interior mutability.
|
||||
|
||||
It targets embedded Rust on `embassy` first, with full tokio support
|
||||
on the side. Same chart, both runtimes.
|
||||
|
||||
## A first chart — a blinker
|
||||
|
||||
```rust
|
||||
use hsmc::{statechart, Duration};
|
||||
|
||||
pub struct Ctx {
|
||||
pub on: bool, // mirrors the LED's current state
|
||||
pub blinks: u32, // count of times we've turned it on
|
||||
}
|
||||
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(Off); // start with the LED off
|
||||
|
||||
state Off {
|
||||
entry: light_off;
|
||||
on(after Duration::from_millis(500)) => On;
|
||||
}
|
||||
state On {
|
||||
entry: light_on;
|
||||
on(after Duration::from_millis(500)) => Off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlinkerActions for BlinkerActionContext<'_> {
|
||||
async fn light_off(&mut self) { self.on = false; }
|
||||
async fn light_on(&mut self) { self.on = true; self.blinks += 1; }
|
||||
}
|
||||
```
|
||||
|
||||
Two states, two timer-driven transitions, two entry actions. The
|
||||
runtime arms each state's timer when you enter it and cancels it
|
||||
when you leave; `default(Off)` picks the initial state. Land in
|
||||
`Off`, fire `light_off`, arm the 500 ms timer; when it elapses,
|
||||
transition to `On`, fire `light_on`, arm the next 500 ms timer.
|
||||
Repeat.
|
||||
|
||||
That's `hsmc` at its smallest: states, timers, and one entry action
|
||||
per state. No event channel, no async I/O, no hierarchy yet.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Add to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.5", features = ["embassy"] } # firmware
|
||||
# or
|
||||
hsmc = { version = "0.5", features = ["tokio"] } # desktop / tests
|
||||
```
|
||||
|
||||
Run the Blinker under tokio:
|
||||
|
||||
```rust
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
That's the whole driver. A chart runs on a single task — `flavor =
|
||||
"current_thread"` keeps tokio out of the multi-threaded scheduler so
|
||||
you don't have to think about `Send` bounds on your context, your
|
||||
events, or your `during:` futures. Normal Rust, no extra wrapping.
|
||||
|
||||
Run it under embassy:
|
||||
|
||||
```rust
|
||||
#[embassy_executor::task]
|
||||
async fn blink_task() {
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
## A bigger chart — events, payloads, async I/O
|
||||
|
||||
The Blinker doesn't talk to anything outside itself. A real device
|
||||
does. Here's a radio that receives commands from somewhere
|
||||
(button press, RPC call, ISR), continuously listens for packets while
|
||||
in the `Receiving` state, and stops when told:
|
||||
|
||||
```rust
|
||||
statechart! {
|
||||
@@ -25,121 +113,132 @@ statechart! {
|
||||
}
|
||||
```
|
||||
|
||||
## What it does
|
||||
Three things here that the Blinker didn't have:
|
||||
|
||||
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
|
||||
actions, LCA-aware transitions, default-child descent, `terminate`.
|
||||
- **Event-driven transitions and actions.** Typed payload bindings:
|
||||
`on(PacketRx { rssi: i16 }) => handler;`.
|
||||
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
|
||||
`on(every Duration::from_millis(100)) => tick;`.
|
||||
- **`during:` activities.** Async functions that run while a state is
|
||||
active and produce events. Multiple durings can borrow disjoint fields
|
||||
of the context concurrently via Rust's native split borrow — no
|
||||
`RefCell`, no `Mutex`, no `UnsafeCell` in user code.
|
||||
- **`m.run().await` is the whole task body.** Races active durings, the
|
||||
external event channel, and timer deadlines. The MCU sleeps between
|
||||
events (embassy's cooperative executor parks all futures).
|
||||
- **Unified observability.** One observation per atom of execution
|
||||
fans out at compile time to a journal (`Vec<TraceEvent>`) and/or
|
||||
textual trace backends (`defmt`, `log`, `tracing`) — they share a
|
||||
single vocabulary, so they cannot diverge. With no sink feature on,
|
||||
every site expands to `()` (zero overhead). See
|
||||
[Observability](#observability--one-journal-multiple-outputs).
|
||||
- **Deterministic + mechanically verified.** The `journal` feature
|
||||
records every action, transition, timer, and event in order — same
|
||||
inputs, byte-identical journal. The runtime queue and timer table
|
||||
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
|
||||
mechanically (`just verify`). 159 tests pin every spec rule
|
||||
(journal sequences + trace-format regression + integration).
|
||||
- **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
|
||||
statechart compiles under v0.4 unchanged. The 0.4 breaks are in
|
||||
observability surface area only — see
|
||||
[Migration](#migration-from-v03).
|
||||
- **Events.** `events: Ev;` declares the input enum. External code
|
||||
drives the chart with `m.sender().send(Ev::StartRx)`; in-chart
|
||||
actions can `emit(Ev::Whatever)` to queue a follow-up. Handlers
|
||||
pattern-match the variant and bind its payload directly:
|
||||
`on(PacketRx { rssi, snr }) => record_packet;` makes `rssi` and
|
||||
`snr` available to the action.
|
||||
- **`during:` activities.** `next_packet(lora, rx_buf)` is an async
|
||||
fn that runs while `Receiving` is active and is dropped when the
|
||||
state exits. It gets `&mut` borrows of just the `lora` and
|
||||
`rx_buf` fields of the context — Rust's split-borrow rules let
|
||||
several `during:`s on the active path run concurrently without a
|
||||
`RefCell` or `Mutex` in sight.
|
||||
- **The runtime races them all.** `m.run()` simultaneously polls the
|
||||
`during:`, listens on the event channel, and waits for the next
|
||||
timer deadline; whichever fires first drives the next transition.
|
||||
The MCU sleeps in between.
|
||||
|
||||
## How a chart behaves — the building blocks
|
||||
For nested-state hierarchies, `emit(...)`, `terminate`, and
|
||||
`on(every D)`, see the [microwave example](hsmc/examples/microwave.rs).
|
||||
|
||||
Eight rules, each building on the previous. Internalize these and every
|
||||
edge case below falls out — you don't have to memorize anything else.
|
||||
## What you get
|
||||
|
||||
- **Nested states with proper transitions.** Exit a deeply nested
|
||||
state and every enclosing state's exits run in the right order, no
|
||||
manual bookkeeping. A transition is just `=> Target;` and the
|
||||
runtime walks the tree (LCA dispatch). Includes `entry:`/`exit:`
|
||||
actions, optional `default(...)` initial transitions, and
|
||||
`terminate`.
|
||||
- **Events with typed payloads.** Pattern-match a variant and bind
|
||||
its fields right in the chart: `on(PacketRx { rssi: i16 }) =>
|
||||
record_packet;`. The compiler type-checks the binding against your
|
||||
event enum.
|
||||
- **Timers as first-class triggers.** `on(after 5s) => Next;` for a
|
||||
one-shot, `on(every 100ms) => tick;` for periodic. Armed when the
|
||||
declaring state is entered, cancelled when it's exited — no
|
||||
`select!` plumbing in your code.
|
||||
- **Async work scoped to a state's lifetime.** A `during:` is an
|
||||
async fn that runs while a state is active and is dropped when you
|
||||
leave. Multiple durings on the active path race concurrently and
|
||||
split-borrow disjoint context fields — no `RefCell`, no `Mutex`,
|
||||
no `unsafe`.
|
||||
- **`m.run().await` is the whole task.** It races durings, the event
|
||||
channel, and timer deadlines, and parks the executor between them.
|
||||
No manual loop, no busy-waits, no `Send` headaches under
|
||||
`current_thread` tokio or embassy.
|
||||
- **Observability that's one feature flag away.** Add
|
||||
`features = ["trace-log"]` (or `trace-defmt` / `trace-tracing`) and
|
||||
every action, transition, timer, and event becomes a log line in
|
||||
whichever framework your crate already uses — `defmt` for firmware,
|
||||
`log` for desktop, `tracing` for structured spans. With no sink
|
||||
feature, the observation calls compile to nothing.
|
||||
- **Deterministic + machine-checked.** The `journal` feature records
|
||||
every action, transition, timer, and event in order; identical
|
||||
input produces a byte-identical journal. The runtime's event
|
||||
queue and timer table are formally verified — Creusot + Why3 +
|
||||
alt-ergo + z3 discharge the proofs (`just verify`).
|
||||
- **No heap, no `unsafe`, no dynamic dispatch.** Pure monomorphized
|
||||
code. `no_std` clean by default. Designed to fit on a Cortex-M0+
|
||||
under embassy.
|
||||
|
||||
## How a chart behaves — the rules
|
||||
|
||||
Eight rules, each building on the previous. Internalize these and
|
||||
every edge case below falls out — you don't have to memorize anything
|
||||
else.
|
||||
|
||||
The full canonical reference lives at
|
||||
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
|
||||
this section is the on-ramp.
|
||||
|
||||
### 1. The active path — you are in every state from root to leaf
|
||||
### 1. The active path — you are in every state from root to the innermost active state
|
||||
|
||||
At any moment, the machine is in a **path** of states from the root down
|
||||
to one innermost state. If you're in `C` inside `B` inside `A`, you're
|
||||
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
|
||||
active until termination.
|
||||
to one innermost active state. If you're in `C` inside `B` inside `A`,
|
||||
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
|
||||
always active until termination.
|
||||
|
||||
The innermost active state is usually a leaf, but it can also be a
|
||||
composite that has children but no `default(...)` — see rule 5.
|
||||
|
||||
This is the most important rule. Every rule below is downstream of it.
|
||||
If a behavior somewhere else seems weird, come back here — it's
|
||||
probably the resolution.
|
||||
|
||||
### 2. Default-descent — composites auto-enter their default child
|
||||
### 2. Transitions — exit to LCA, enter to target
|
||||
|
||||
Every state with children declares one `default(...)`. When a composite
|
||||
state is entered, its entry actions run first, **then** the default
|
||||
child is entered, recursively, until a leaf.
|
||||
|
||||
So `Root` with `default(A)` and `A` with `default(B)` means: the
|
||||
machine starts by entering `Root` → `A` → `B`, all on first step.
|
||||
|
||||
### 3. Event bubbling — leaf first, first handler wins
|
||||
|
||||
An event arrives. Search starts at the **innermost** active state. If
|
||||
that state has a handler, it runs and the event is consumed. If not,
|
||||
walk up to the parent and check there. Repeat. If you reach the root
|
||||
with no handler, the event is silently discarded.
|
||||
|
||||
Three corollaries:
|
||||
|
||||
- Leaf shadows ancestor for the same trigger.
|
||||
- Multiple handlers in one state on the same trigger fire in
|
||||
declaration order; the transition (if any) fires last.
|
||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
||||
it; it fires only in that state's scope.
|
||||
|
||||
### 4. Transitions — exit to LCA, enter to target
|
||||
|
||||
Any transition's algorithm is uniform:
|
||||
A transition moves the active path from the current innermost state to
|
||||
some target state. The algorithm is uniform regardless of what
|
||||
triggered it (event, timer, or default — see later rules):
|
||||
|
||||
1. Find the LCA (lowest common ancestor) of current and target.
|
||||
2. Walk **up** from current, exiting each state, until you hit the LCA.
|
||||
The LCA itself is **not** exited.
|
||||
3. Walk **down** from the LCA to target, entering each state. The LCA
|
||||
is **not** re-entered.
|
||||
4. If target has children, default-descend (rule 2).
|
||||
|
||||
The LCA always exists because root is always an upper bound. State
|
||||
names are globally unique across the chart, so a transition can target
|
||||
**any** state — siblings, cousins, ancestors, the root itself
|
||||
(`on(Trig) => MyChart;`).
|
||||
|
||||
### 5. The up-transition rule — never re-enter what you never left
|
||||
### 3. The up-transition rule — never re-enter what you never left
|
||||
|
||||
If your transition target is **already on the active path** (i.e., it's
|
||||
an ancestor of the innermost state), then steps 3 and 4 of the
|
||||
transition algorithm do nothing. You exit the states strictly between
|
||||
current and target; the target itself is **not** re-entered.
|
||||
an ancestor of the innermost state), then step 3 of the transition
|
||||
algorithm does nothing. You exit the states strictly between current
|
||||
and target; the target itself is **not** re-entered.
|
||||
|
||||
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s `default` is not
|
||||
followed. `A`'s timers don't restart. `A`'s durings keep running.
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s timers don't restart.
|
||||
`A`'s durings keep running.
|
||||
|
||||
You cannot enter a state you never left.
|
||||
|
||||
### 6. Entry / exit ordering
|
||||
### 4. Entry / exit ordering
|
||||
|
||||
Direction is set by the path:
|
||||
|
||||
- **Entries** fire **outer-to-inner**: when entering down through a
|
||||
path, the outermost state's entries fire first, then the next, then
|
||||
the leaf's. Within a single state, entry actions fire in declaration
|
||||
order. After a state's entries finish, its `during:` activities
|
||||
start. Then default-descent (rule 2) applies recursively.
|
||||
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
|
||||
the innermost's. Within a single state, entry actions fire in
|
||||
declaration order. After a state's entries finish, its `during:`
|
||||
activities start.
|
||||
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
|
||||
grandparent. Within a single state, exit actions fire in declaration
|
||||
order. Before a state's exits begin, its `during:` activities are
|
||||
cancelled.
|
||||
@@ -148,6 +247,58 @@ So a full transition reads: cancel durings on the way out → run exits
|
||||
on the way out → run entries on the way in → start durings on the way
|
||||
in.
|
||||
|
||||
### 5. `default(...)` — a transition that fires immediately on entry
|
||||
|
||||
Any state **may** declare one `default(...)`. It's a transition — same
|
||||
LCA-aware algorithm as rule 2 — that fires **immediately** after the
|
||||
declaring state's entry actions finish (and its durings start). The
|
||||
target may be **any** state in the chart: a child, a sibling, an
|
||||
ancestor, anywhere. Defaults chain: if the resulting target also has
|
||||
a `default(...)`, it fires too, and so on until a state with no
|
||||
default is reached. The compiler rejects cycles in the default graph,
|
||||
so the chain is always finite.
|
||||
|
||||
The classic case is a composite descending to a leaf:
|
||||
```
|
||||
state Root { default(A); state A { default(B); state B {} } }
|
||||
```
|
||||
Entering `Root` fires its default → enter `A` → fires its default →
|
||||
enter `B`. The path lands at `B`.
|
||||
|
||||
But because `default` is a real transition, you can also redirect:
|
||||
```
|
||||
state Foyer { default(LivingRoom); } // sibling — entering Foyer
|
||||
state LivingRoom {} // exits Foyer to LivingRoom
|
||||
```
|
||||
Entering `Foyer` fires its default `Foyer → LivingRoom`. LCA = parent of
|
||||
both; Foyer's exits run, LivingRoom's entries run. Foyer was entered
|
||||
and exited as part of one tick.
|
||||
|
||||
If a state has no `default(...)`, nothing happens after its entries —
|
||||
the state itself is the resting innermost active state until something
|
||||
explicitly transitions away. This is the "microwave" pattern: a
|
||||
`Standby` state with sub-modes that the chart enters only on demand.
|
||||
|
||||
A consequence of rule 3 (up-transitions): an ancestor's `default(...)`
|
||||
only fires when that ancestor is freshly entered. Transitioning *up*
|
||||
to an ancestor does not re-enter it, so its default does not re-fire.
|
||||
|
||||
### 6. Event bubbling — innermost first, first handler wins
|
||||
|
||||
An event arrives. Search starts at the **innermost** active state. If
|
||||
that state has a handler, it runs and the event is consumed (the
|
||||
handler may execute a transition per rule 2). If not, walk up to the
|
||||
parent and check there. Repeat. If you reach the root with no handler,
|
||||
the event is silently discarded.
|
||||
|
||||
Three corollaries:
|
||||
|
||||
- Innermost shadows ancestor for the same trigger.
|
||||
- Multiple handlers in one state on the same trigger fire in
|
||||
declaration order; the transition (if any) fires last.
|
||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
||||
it; it fires only in that state's scope.
|
||||
|
||||
### 7. Timers — armed by state lifecycle
|
||||
|
||||
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
|
||||
@@ -168,7 +319,7 @@ restarts its timers from zero.
|
||||
durings on the active path race; each borrows disjoint `&mut` fields
|
||||
of the context (Rust's split-borrow checker enforces this at compile
|
||||
time).
|
||||
- **Termination** is just rules 6 + 4 with target = "outside the
|
||||
- **Termination** is just rules 2 + 4 with target = "outside the
|
||||
chart": exit the entire active path inner-to-outer, then stop.
|
||||
Pending events drop. In-flight durings drop at their next `.await`.
|
||||
|
||||
@@ -178,78 +329,6 @@ spells out edge cases (queue overflow surfacing, same-tick timer ties,
|
||||
self-transitions on composites) but the rules above cover the
|
||||
overwhelming majority of charts.
|
||||
|
||||
### What `hsmc` deliberately does NOT have
|
||||
|
||||
The bet is on **simple, consistent semantics**. These features are
|
||||
omitted on purpose; for each, what to do instead:
|
||||
|
||||
| Missing feature | Use instead |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
|
||||
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
|
||||
| History states | Store last child explicitly in your context; transition to it on re-entry |
|
||||
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
|
||||
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
|
||||
| State-local context | All state in the root context; durings borrow disjoint fields |
|
||||
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
|
||||
| Runtime statechart modification | Design every variation up front as states |
|
||||
|
||||
## Quickstart
|
||||
|
||||
Add to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"]
|
||||
```
|
||||
|
||||
Minimal timer-only machine:
|
||||
|
||||
```rust
|
||||
use hsmc::{statechart, Duration};
|
||||
|
||||
pub struct Ctx { pub ticks: u32 }
|
||||
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(On);
|
||||
|
||||
state On {
|
||||
entry: light_on;
|
||||
on(after Duration::from_millis(500)) => Off;
|
||||
}
|
||||
state Off {
|
||||
on(after Duration::from_millis(500)) => On;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlinkerActions for BlinkerActionContext<'_> {
|
||||
async fn light_on(&mut self) { self.ticks += 1; }
|
||||
}
|
||||
```
|
||||
|
||||
Drive it under tokio:
|
||||
|
||||
```rust
|
||||
let mut m = Blinker::new(Ctx { ticks: 0 });
|
||||
tokio::task::LocalSet::new().run_until(m.run()).await.unwrap();
|
||||
```
|
||||
|
||||
Or under embassy:
|
||||
|
||||
```rust
|
||||
static CHANNEL: Channel<CriticalSectionRawMutex, __NoEvents, 8> = Channel::new();
|
||||
// (timer-only machines don't need a channel; omit `events:` and use `Blinker::new(ctx)`.)
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn blink_task() {
|
||||
let mut m = Blinker::new(Ctx { ticks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
## `during:` — async activities scoped to a state
|
||||
|
||||
A `during:` runs while its state is on the active path. It's a plain async
|
||||
@@ -357,7 +436,7 @@ For `statechart! Foo { ... }` the macro emits:
|
||||
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
|
||||
| `fn send(ev)` | Raw push, non-draining. For tests. |
|
||||
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
|
||||
| `current_state()` | The active leaf state. |
|
||||
| `current_state()` | The innermost active state. |
|
||||
| `is_terminated()` | True after `terminate` event. |
|
||||
| `has_pending_events()` | Queue non-empty. |
|
||||
| `context()` / `context_mut()` | Borrow the root context. |
|
||||
@@ -366,187 +445,59 @@ For `statechart! Foo { ... }` the macro emits:
|
||||
|
||||
## Feature flags
|
||||
|
||||
| Feature | Effect |
|
||||
| --------- | -------------------------------------------------------------------------- |
|
||||
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
||||
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
|
||||
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`. |
|
||||
| Feature | Effect |
|
||||
| --------------- | -------------------------------------------------------------------------- |
|
||||
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
||||
| `tokio` | Async actions + `run()`. Single-thread (`flavor = "current_thread"`). |
|
||||
| `embassy` | Async actions + `run()`. Embassy's cooperative executor. `no_std`. |
|
||||
| `journal` | In-memory `Vec<TraceEvent>` of every observation (replay, byte-deterministic). |
|
||||
| `trace-defmt` | Auto-emit `defmt::*` log lines for every observation. |
|
||||
| `trace-log` | Auto-emit `log::*` log lines for every observation. |
|
||||
| `trace-tracing` | Auto-emit `tracing::*` events with structured fields. |
|
||||
|
||||
`embassy` and `tokio` are mutually exclusive — enabling both is a compile
|
||||
error.
|
||||
|
||||
## Migration from v0.3
|
||||
|
||||
The 0.4 breaks are in the observability surface area only; chart
|
||||
syntax and runtime API are unchanged.
|
||||
|
||||
### 1. Trace prefix changed
|
||||
|
||||
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
|
||||
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
|
||||
log filters or grep patterns. The verb after the prefix is now a
|
||||
single hyphenated token followed by `key=value` pairs (logfmt).
|
||||
|
||||
### 2. `TraceEvent::TransitionFired` carries `reason`
|
||||
|
||||
Add `reason` to any pattern matches:
|
||||
|
||||
```rust
|
||||
// before:
|
||||
TraceEvent::TransitionFired { from, to } => …
|
||||
// after:
|
||||
TraceEvent::TransitionFired { from, to, reason } => …
|
||||
// or, if you don't need it:
|
||||
TraceEvent::TransitionFired { from, to, .. } => …
|
||||
```
|
||||
|
||||
The new field carries `TransitionReason::Event { event }`,
|
||||
`Timer { state, timer }`, or `Internal` so consumers can see what
|
||||
triggered the transition.
|
||||
|
||||
### 3. New `TraceEvent` variants
|
||||
|
||||
If you `match` exhaustively on `TraceEvent`, add arms for the new
|
||||
variants. Otherwise nothing changes:
|
||||
|
||||
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
|
||||
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
|
||||
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
|
||||
- `EventReceived { event }` — fires when an event is popped from the
|
||||
queue, before handler search.
|
||||
|
||||
These are pure observation — no runtime semantic change.
|
||||
|
||||
### 4. `trace;` keyword is a no-op
|
||||
|
||||
Charts that declared `trace;` continue to compile. The keyword no
|
||||
longer gates emission — observation is now controlled purely by
|
||||
which `trace-*` cargo feature you enable. You can leave `trace;` in
|
||||
or remove it; both work.
|
||||
|
||||
### 5. Trace backends are no longer mutually exclusive
|
||||
|
||||
Previously, enabling more than one of `trace-defmt` / `trace-log` /
|
||||
`trace-tracing` was a compile error. Now they fan out independently:
|
||||
turn on whichever you want.
|
||||
|
||||
## Migration from v0.1
|
||||
|
||||
v0.1 statecharts continue to compile. New features are purely additive:
|
||||
|
||||
### 1. `events:` is now optional
|
||||
|
||||
Previously required; now omit for pure timer-only machines:
|
||||
|
||||
```rust
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(On);
|
||||
state On { on(after Duration::from_millis(500)) => Off; }
|
||||
state Off { on(after Duration::from_millis(500)) => On; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `dispatch(ev).await` replaces the push+drain helper
|
||||
|
||||
Before (v0.1, in the dogfood firmware):
|
||||
|
||||
```rust
|
||||
async fn push<M>(m: &mut M, ev: E) {
|
||||
m.send(ev).expect("queue full");
|
||||
while m.has_pending_events() {
|
||||
m.step(Duration::ZERO).await;
|
||||
}
|
||||
}
|
||||
push(&mut machine, ev).await;
|
||||
```
|
||||
|
||||
After (v0.2):
|
||||
|
||||
```rust
|
||||
machine.dispatch(ev).await.unwrap();
|
||||
```
|
||||
|
||||
### 3. `during:` replaces the imperative I/O outer loop
|
||||
|
||||
Before — a radio task with per-state match and select:
|
||||
|
||||
```rust
|
||||
loop {
|
||||
match machine.current_state() {
|
||||
RadioState::Receiving => {
|
||||
match select(rx_once(&mut lora, &cfg, &mut rx_buf),
|
||||
commands.receive()).await {
|
||||
Either::First(r) => handle_rx(r, &mut machine, ...).await,
|
||||
Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
|
||||
}
|
||||
}
|
||||
_ => { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After — the statechart owns the loop:
|
||||
|
||||
```rust
|
||||
state Receiving {
|
||||
during: next_packet(lora, rx_buf);
|
||||
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
|
||||
on(StopRx) => Idle;
|
||||
}
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
|
||||
let mut m = Radio::new(RadioCtx { lora, ... }, ch);
|
||||
m.run().await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Purpose |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
|
||||
| `microwave_tui` | TUI variant of microwave. |
|
||||
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
|
||||
| `embassy_full` | Embassy feature demonstration. |
|
||||
|
||||
Run `cargo run --example during_radio --features tokio` to see the
|
||||
`during:` pattern end-to-end.
|
||||
|
||||
## Verification
|
||||
|
||||
`hsmc` ships with a Creusot deductive-verification crate
|
||||
(`verification/`) that proves the runtime's `EventQueue` and
|
||||
`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3
|
||||
discharge every VC mechanically. Run:
|
||||
|
||||
```bash
|
||||
just verify
|
||||
```
|
||||
|
||||
The recipe is self-installing (mise pulls in the pinned nightly,
|
||||
opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone
|
||||
gets to a green proof in one command. See
|
||||
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
|
||||
spec-rule → proof mapping.
|
||||
|
||||
Beyond Creusot, the suite includes:
|
||||
|
||||
- **159 integration tests** — full chart behavior, byte-equal expected-
|
||||
vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
|
||||
format regression tests pinning every observation verb's exact
|
||||
textual rendering.
|
||||
- **0 missed mutants** under `cargo mutants --features
|
||||
tokio,journal,trace-log`.
|
||||
- **Miri**: 68 tests, no UB.
|
||||
error. The `trace-*` and `journal` features are independently composable
|
||||
with each other and with the runtime feature; with no sink feature on,
|
||||
the trace points compile to nothing (zero overhead).
|
||||
|
||||
## Observability — one journal, multiple outputs
|
||||
|
||||
Every chart emits a single observation per atom of execution. That
|
||||
observation fans out to whichever sinks you compile in:
|
||||
On hsmc's side, turning on observability is a single Cargo feature.
|
||||
On your side, it's whatever you'd already do to wire `defmt` / `log`
|
||||
/ `tracing` to an output — hsmc does not initialize a logger
|
||||
(libraries shouldn't; `main` owns that decision).
|
||||
|
||||
If your firmware crate already uses `defmt`, add the feature and
|
||||
you're done — chart events interleave with the rest of your defmt
|
||||
output:
|
||||
|
||||
```toml
|
||||
hsmc = { version = "0.5", features = ["embassy", "trace-defmt"] }
|
||||
```
|
||||
|
||||
For a desktop crate, pick `log` or `tracing` and do that framework's
|
||||
normal one-line init in `main`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.5", features = ["tokio", "trace-tracing"] }
|
||||
tracing-subscriber = "0.3"
|
||||
```
|
||||
|
||||
```rust
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
Swap `trace-tracing` for `trace-log` and `tracing_subscriber::fmt::init()`
|
||||
for `env_logger::init()` if you prefer the `log` ecosystem; the rest is
|
||||
identical. With no sink feature enabled at all, the observation calls
|
||||
compile to `()` — the runtime is byte-identical to a chart with no
|
||||
instrumentation.
|
||||
|
||||
| Feature | Sink |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
@@ -555,10 +506,9 @@ observation fans out to whichever sinks you compile in:
|
||||
| `trace-log` | `log::*` log lines |
|
||||
| `trace-tracing` | `tracing::*` events with structured fields |
|
||||
|
||||
With **no** sink feature on, observation is fully elided at compile
|
||||
time — zero overhead. Multiple sinks can be enabled simultaneously;
|
||||
the journal IS the observation stream and trace backends are textual
|
||||
renderings of it, so they cannot diverge.
|
||||
Multiple sinks can be enabled simultaneously; the journal IS the
|
||||
observation stream and trace backends are textual renderings of it,
|
||||
so they cannot diverge.
|
||||
|
||||
The textual format is logfmt-style — greppable and regex-parseable:
|
||||
|
||||
@@ -609,28 +559,70 @@ transition: an event (`event:VariantName`), a timer
|
||||
(`timer:State/timer-id`), or `internal`. Real-life captures can be
|
||||
diffed against expected behavior the same way the journal can.
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Purpose |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
|
||||
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
|
||||
| `embassy_full` | Embassy feature demonstration. |
|
||||
|
||||
Run `cargo run --example during_radio --features tokio` to see the
|
||||
`during:` pattern end-to-end.
|
||||
|
||||
## Verification
|
||||
|
||||
`hsmc` ships with a Creusot deductive-verification crate
|
||||
(`verification/`) that proves the runtime's `EventQueue` and
|
||||
`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3
|
||||
discharge every VC mechanically. Run:
|
||||
|
||||
```bash
|
||||
just verify
|
||||
```
|
||||
|
||||
The recipe is self-installing (mise pulls in the pinned nightly,
|
||||
opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone
|
||||
gets to a green proof in one command. See
|
||||
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
|
||||
spec-rule → proof mapping.
|
||||
|
||||
Beyond Creusot, the suite includes:
|
||||
|
||||
- **Integration tests** — full chart behavior and per-feature
|
||||
determinism, with byte-equal expected-vs-actual journals on the
|
||||
`det_*` deterministic-flow tests, plus trace-format regression
|
||||
tests pinning every observation verb's textual rendering. Run
|
||||
with `cargo test --workspace --features tokio,journal`.
|
||||
- **`cargo mutants`** — full coverage under `cargo mutants --features
|
||||
tokio,journal,trace-log`.
|
||||
- **Miri** — clean, no UB on the runtime crates.
|
||||
|
||||
## What `hsmc` deliberately does NOT have
|
||||
|
||||
The bet is on **simple, consistent semantics**. These features are
|
||||
omitted on purpose; for each, what to do instead:
|
||||
|
||||
| Missing feature | Use instead |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
|
||||
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
|
||||
| History states | Store last child explicitly in your context; transition to it on re-entry |
|
||||
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
|
||||
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
|
||||
| State-local context | All state in the root context; durings borrow disjoint fields |
|
||||
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
|
||||
| Runtime statechart modification | Design every variation up front as states |
|
||||
| Multi-threaded tokio | `flavor = "current_thread"` is the supported path — one task per chart |
|
||||
|
||||
See [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
|
||||
§ "What `hsmc` deliberately does NOT have" for the rationale on each.
|
||||
|
||||
## Status
|
||||
|
||||
- v0.1 spec: fully implemented.
|
||||
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
|
||||
drain-on-send. Up-transitions no longer exit/re-enter the target.
|
||||
- v0.3: `journal` feature + deterministic trace + replay; root
|
||||
state targetable by chart name; Creusot verification of the
|
||||
runtime data structures; 113-test deterministic-flow suite.
|
||||
- v0.4: unified observation pipeline (journal + trace share one
|
||||
vocabulary, fan out to multiple sinks at compile time); logfmt-
|
||||
style trace format with `[statechart:Name]` prefix; transition
|
||||
reasons; entry/exit/transition begin/end markers; multiple trace
|
||||
backends now compose; chart-level `trace;` keyword deprecated.
|
||||
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
|
||||
- Out of scope (deliberate): guard conditions, orthogonal/parallel
|
||||
regions, history states, deferred events, internal transitions,
|
||||
state-local context, event priorities beyond FIFO, runtime
|
||||
statechart modification, multi-threaded tokio. See
|
||||
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
|
||||
§ "What `hsmc` deliberately does NOT have" for the rationale and
|
||||
what to use instead.
|
||||
Pre-1.0. Per-release breaking changes are documented in
|
||||
[`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed MIT OR Apache-2.0.
|
||||
MIT. Copyright (c) 2026 Stephen Waits <steve@waits.net>. See [LICENSE](LICENSE).
|
||||
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
|
||||
to the target, calling entries along the way.
|
||||
- **Action (without transition)** — a function call attached to a trigger.
|
||||
No state change. That's it.
|
||||
- **Default child** — every non-leaf state declares one default child.
|
||||
Whenever a state with children is entered, the default child is entered
|
||||
immediately after that state's entry actions finish — it's effectively
|
||||
an automatic transition into the child.
|
||||
- **Default transition** — any state **may** declare one `default(...)`.
|
||||
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
|
||||
transitions) that fires immediately after the declaring state's entry
|
||||
actions finish and its durings start. The target may be any state in
|
||||
the chart — a child (the classic descent case), a sibling, an
|
||||
ancestor, anywhere. Defaults chain (each link is itself a transition)
|
||||
until they reach a state with no default. The compiler rejects cycles
|
||||
in the default graph at compile time, so every chain terminates. When
|
||||
a state has no `default`, nothing else happens after entry — the
|
||||
state itself is the resting innermost active state.
|
||||
- **Trigger: event vs. duration** — an event is a variant of your event
|
||||
enum. A duration is a timer (`after Duration` fires once, `every
|
||||
Duration` fires repeatedly). Triggers attach to transitions and to
|
||||
@@ -71,7 +77,8 @@ seems weird, come back to this one — it's probably the resolution.
|
||||
|
||||
`current_state()` returns the innermost active state, which is usually a
|
||||
leaf, but can be a composite (a state with children) if you got there via
|
||||
an up-transition (see below) and haven't moved since.
|
||||
an up-transition (see below) and haven't moved since, or if you
|
||||
transitioned into a composite that has no `default` declared.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,8 +93,9 @@ and still nothing handles it, the event is silently discarded.
|
||||
|
||||
That's all. Three other things follow from this rule:
|
||||
|
||||
- A handler on a leaf state shadows a handler on its ancestor for the same
|
||||
event. The leaf wins because the search starts there.
|
||||
- A handler on the innermost active state shadows a handler on any
|
||||
ancestor for the same event. The innermost wins because the search
|
||||
starts there.
|
||||
- If a single state has multiple handlers (actions and/or a transition)
|
||||
for the same trigger, the actions fire in declaration order, then the
|
||||
transition fires last.
|
||||
@@ -108,8 +116,8 @@ in `statechart! { Foo { ... } }`, an `on(Trig) => Foo;` is a valid
|
||||
transition whose target is the root. From any state inside the chart this
|
||||
is an up-transition (root is always on the active path) — exiting only
|
||||
the states strictly between current and root, with no entry actions and
|
||||
no default-descent on root. Use this when you want to wind everything
|
||||
back up to the chart's own scope.
|
||||
no default firing on root (the root wasn't freshly entered). Use this
|
||||
when you want to wind everything back up to the chart's own scope.
|
||||
|
||||
To run a transition:
|
||||
|
||||
@@ -121,8 +129,10 @@ To run a transition:
|
||||
itself is **not** exited.
|
||||
3. Walk **down** from the LCA to the target, entering each state on the
|
||||
way (run entry actions → start durings). The LCA is **not** re-entered.
|
||||
4. If the target has children, follow the default-child chain down to a
|
||||
leaf, entering each as you go.
|
||||
4. If the target declares a `default(...)`, fire it as a recursive
|
||||
transition (using the same algorithm). Defaults chain until a state
|
||||
with no default is reached. Compile-time cycle check guarantees
|
||||
termination.
|
||||
|
||||
The three flavors below are just three relationships between current and
|
||||
target. The same algorithm covers all of them — they only look different
|
||||
@@ -131,8 +141,9 @@ in which steps fire.
|
||||
### Transitioning to a sibling (or anywhere not on your active path)
|
||||
|
||||
The standard case. LCA is somewhere strictly above both current and
|
||||
target. Exit up to LCA, enter down to target, default-descend if the
|
||||
target has children. Already covered above.
|
||||
target. Exit up to LCA, enter down to target, then if the target
|
||||
declares a `default(...)`, fire it as a chained transition. Already
|
||||
covered above.
|
||||
|
||||
### Transitioning to yourself
|
||||
|
||||
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
|
||||
exited and re-entered.
|
||||
|
||||
If the target happens to be a composite (it has children, but no child is
|
||||
active right now because you got there via an up-transition), the same
|
||||
default-child rule still applies on re-entry: exit the composite, re-enter
|
||||
it, then descend through its default chain. Self-transitions don't add a
|
||||
new rule — they're just the case where source and target are the same
|
||||
state.
|
||||
active right now because you got there via an up-transition or because
|
||||
the composite has no `default`), the same rule still applies on re-entry:
|
||||
exit the composite, re-enter it, then descend through its default chain
|
||||
if it declares one. A composite without `default` is exited, re-entered,
|
||||
and that's it — no further descent. Self-transitions don't add a new
|
||||
rule — they're just the case where source and target are the same state.
|
||||
|
||||
Personally I don't find self-transitions all that valuable, but people
|
||||
talk about them all the time, so they're supported.
|
||||
@@ -207,7 +219,11 @@ Putting it all together:
|
||||
outermost state's entry actions fire first, then its child's, and so on
|
||||
down to the innermost. Within a single state, entry actions fire in
|
||||
declaration order. After entry actions of a state finish, that state's
|
||||
durings start. Then the default child (if any) is entered, recursively.
|
||||
durings start. Then, if the state declares a `default(...)`, the
|
||||
default fires as a transition (which may exit the state we just
|
||||
entered if the target lies elsewhere in the chart) and the chain
|
||||
continues from there. A state without `default` is the end of the
|
||||
path — control stops there.
|
||||
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
|
||||
innermost state exits first, then its parent, and so on up. Within a
|
||||
single state, exit actions fire in declaration order. Before exit
|
||||
@@ -367,11 +383,13 @@ drafts. Edit freely.)_
|
||||
|
||||
| Scenario | What fires |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
|
||||
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
|
||||
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. Durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → if target declares a `default`, fire it; chain continues. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
|
||||
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
|
||||
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
|
||||
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
|
||||
| Timer fires | The declaring state's handler runs (timers don't bubble). |
|
||||
| Event with no handler | Silently discarded. |
|
||||
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
||||
|
||||
File renamed without changes.
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
+124
-68
@@ -100,21 +100,27 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
/// in `transition()` with two slice lookups and two straight slice iterations.
|
||||
///
|
||||
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
|
||||
/// leaf (`self.current`), `to` is the transition target. The same three
|
||||
/// transition shapes are encoded uniformly by what the precomputed paths
|
||||
/// contain:
|
||||
/// innermost state (`self.current`), `to` is the transition target. The
|
||||
/// same three transition shapes are encoded uniformly by what the
|
||||
/// precomputed paths contain:
|
||||
///
|
||||
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
|
||||
/// `from..to` (target excluded; `to` is already on the active path and
|
||||
/// must not be re-entered). enter_path is empty — `transition()` sets
|
||||
/// `self.current = Some(target)` after the loops.
|
||||
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
|
||||
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
|
||||
/// — re-enter target plus descend defaults.
|
||||
/// is `[to]` — re-enter the target.
|
||||
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
|
||||
/// `from` up to but not including LCA. enter_path walks
|
||||
/// LCA-side child of `to` down to `to`, then continues descending
|
||||
/// `__DEFAULT_CHILD` from `to`.
|
||||
/// LCA-side child of `to` down to `to`.
|
||||
///
|
||||
/// Default-descent is **not** folded into these paths. After `transition()`
|
||||
/// applies the precomputed exit/enter, it follows the default chain by
|
||||
/// firing further transitions in a loop (`follow_defaults`). This is
|
||||
/// because `default(...)` may target any state in the chart — sibling,
|
||||
/// ancestor, anywhere — so each step of the chain is itself a full
|
||||
/// LCA-aware transition, not a simple child descent. The compile-time
|
||||
/// default-graph cycle check guarantees the loop terminates.
|
||||
///
|
||||
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
|
||||
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
|
||||
@@ -126,7 +132,6 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
let n = states.len();
|
||||
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
|
||||
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
|
||||
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
|
||||
|
||||
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
|
||||
@@ -171,21 +176,15 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
return (exit_path, Vec::new());
|
||||
}
|
||||
|
||||
// Self-transition: external semantics — exit and re-enter target,
|
||||
// then descend defaults.
|
||||
// Self-transition: external semantics — exit and re-enter target.
|
||||
// Default-following (if any) happens in the runtime chain loop.
|
||||
if from == to {
|
||||
let exit_path = vec![to];
|
||||
let mut enter_path = vec![to];
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
return (exit_path, enter_path);
|
||||
return (vec![to], vec![to]);
|
||||
}
|
||||
|
||||
// Lateral: exit `from` up to (not including) LCA; enter from
|
||||
// LCA-child on `to`'s side down to `to`, then descend defaults.
|
||||
// LCA-child on `to`'s side down to `to`. Default-following
|
||||
// happens in the runtime chain loop.
|
||||
let lca_id = lca(from, to);
|
||||
|
||||
let mut exit_path = Vec::new();
|
||||
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
let enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
(exit_path, enter_path)
|
||||
};
|
||||
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
(exit_data, enter_data, exit_range, enter_range)
|
||||
}
|
||||
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
|
||||
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each state, the
|
||||
/// chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
|
||||
/// at the chart top level — see the `det_initial` test for that pattern).
|
||||
///
|
||||
@@ -376,17 +369,21 @@ fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// For each user-declared leaf state that has one or more `during:` activities
|
||||
/// active (either declared on it, on any ancestor, or on root), return its
|
||||
/// state id and the flattened list of durings in leaf-first order. Durings
|
||||
/// declared on the same state follow their declaration order.
|
||||
/// For each state that can be the innermost active state, return its state id
|
||||
/// and the flattened list of `during:` activities active there (declared on it
|
||||
/// or on any ancestor) in leaf-first order. Durings declared on the same state
|
||||
/// follow their declaration order.
|
||||
///
|
||||
/// Leaf states with no active durings are omitted — the run loop uses its
|
||||
/// default (channel+timer) path for those.
|
||||
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
/// A state can be innermost iff it has no children, OR it has children but no
|
||||
/// `default` child (so default-descent stops at it). Composites with a default
|
||||
/// child are always descended past at runtime, so they're skipped here.
|
||||
///
|
||||
/// Innermost-able states with no active durings are omitted — the run loop
|
||||
/// uses its default (channel+timer) path for those.
|
||||
fn active_durings_per_innermost_state(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
let mut out = Vec::new();
|
||||
for s in &ir.states {
|
||||
if !s.children.is_empty() {
|
||||
if !s.children.is_empty() && s.default_child.is_some() {
|
||||
continue;
|
||||
}
|
||||
if s.parent.is_none() {
|
||||
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
|
||||
(idents, quote! { #(#stmts)* })
|
||||
}
|
||||
|
||||
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs
|
||||
/// the active during: futures (split-borrowing context fields), then races
|
||||
/// them against the event channel and the next-timer sleep via
|
||||
/// `tokio::select!`. The default arm — used for leaves with no durings — is
|
||||
/// emitted by the caller.
|
||||
/// Emit the per-state match arms for the tokio run loop, keyed on the
|
||||
/// innermost active state id. Each arm constructs the active during: futures
|
||||
/// (split-borrowing context fields), then races them against the event channel
|
||||
/// and the next-timer sleep via `tokio::select!`. The default arm — used for
|
||||
/// states with no active durings — is emitted by the caller.
|
||||
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -462,7 +459,7 @@ fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// (no `events:` declared, no channel). The default arm races only the timer.
|
||||
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
|
||||
/// compiles unchanged.
|
||||
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n + 2 > 6 {
|
||||
let msg = format!(
|
||||
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// durings on a timer-only path.
|
||||
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n == 0 {
|
||||
continue; // falls through to default (just timer)
|
||||
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
__timer_reason_str(__t_state, __t_trigger)
|
||||
);
|
||||
};
|
||||
#[allow(dead_code)]
|
||||
let _observe_transition_fired_internal = quote! {
|
||||
let observe_transition_fired_internal = quote! {
|
||||
::hsmc::__chart_observe!(
|
||||
TransitionFired,
|
||||
&mut self.__journal,
|
||||
@@ -1890,7 +1886,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// run() bodies to decide whether to exit when no timers are pending: if
|
||||
// the current state has an active during, the run loop should keep
|
||||
// awaiting rather than break (the during might yet produce an event).
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_leaf(ir)
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_innermost_state(ir)
|
||||
.into_iter()
|
||||
.map(|(sid, _)| sid)
|
||||
.collect();
|
||||
@@ -2595,7 +2591,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
|
||||
pub fn is_terminated(&self) -> bool { self.terminated }
|
||||
|
||||
/// Return the current leaf state. Panics if called before the
|
||||
/// Return the innermost active state. Panics if called before the
|
||||
/// first `step()` (§4.4).
|
||||
pub fn current_state(&self) -> #state_enum_name {
|
||||
__state_id_to_public(self.current.expect("machine not started; call step() first"))
|
||||
@@ -2950,12 +2946,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// An empty enter slice (`m == e`) means up-transition: target
|
||||
// is already on the active path, must not be re-entered, and
|
||||
// `self.current` is set to `target` after the exit loop. A
|
||||
// non-empty enter slice ends at the deepest default-descended
|
||||
// leaf; `enter_state_no_descent` sets `self.current` as it
|
||||
// goes, so no fixup is needed on that path.
|
||||
// non-empty enter slice ends at the target itself —
|
||||
// `enter_state_no_descent` sets `self.current` as it goes.
|
||||
// Default-following runs after the path apply via
|
||||
// `follow_defaults`, which fires further transitions for any
|
||||
// chained `default(...)` declarations on the just-entered
|
||||
// state. The compile-time default-graph cycle check
|
||||
// guarantees the chain terminates.
|
||||
// Returns true if any state was newly entered (i.e., the
|
||||
// enter_path was non-empty). False on an up-transition, where
|
||||
// the target was already on the active path.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2965,15 +2967,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid);
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
async fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2983,22 +2988,48 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid).await;
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target);
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target).await;
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root);
|
||||
self.descend_defaults(root);
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults();
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root).await;
|
||||
self.descend_defaults(root).await;
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
@@ -3041,21 +3072,46 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
#observe_entered
|
||||
}
|
||||
|
||||
// After a state's entries finish (initial entry or any
|
||||
// transition), follow its `default(...)` chain by firing
|
||||
// further transitions. Each step is a full LCA-aware
|
||||
// transition because the default target may be a sibling,
|
||||
// ancestor, or anywhere else — not just a direct child. The
|
||||
// compile-time default-graph cycle check guarantees the loop
|
||||
// terminates.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child);
|
||||
cur = child;
|
||||
fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target);
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
// If the default fired an up-transition (no new state
|
||||
// entered), don't keep chaining — the ancestor we
|
||||
// landed on was already on the active path; its
|
||||
// default was not "freshly entered" and must not fire.
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child).await;
|
||||
cur = child;
|
||||
async fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target).await;
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-6
@@ -79,7 +79,17 @@ pub struct StateIr {
|
||||
pub name: Ident,
|
||||
pub parent: Option<u16>,
|
||||
pub depth: u8,
|
||||
/// Target of this state's `default(...)` declaration, if any. Resolved
|
||||
/// in `resolve_transitions`. The target may be any state in the chart
|
||||
/// (not just a direct child) — `default(...)` is a transition that
|
||||
/// fires immediately after the declaring state's entries, using the
|
||||
/// same LCA-aware algorithm as event-driven transitions. The
|
||||
/// default-graph cycle check in `validate.rs` ensures the chain
|
||||
/// terminates.
|
||||
pub default_child: Option<u16>,
|
||||
/// Original ident from the parse tree, kept until resolution so we can
|
||||
/// emit good error spans on unknown targets / cycles.
|
||||
pub default_target_ident: Option<(Ident, Span)>,
|
||||
pub entries: Vec<u16>, // action ids
|
||||
pub exits: Vec<u16>,
|
||||
pub handlers: Vec<HandlerIr>,
|
||||
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
|
||||
parent,
|
||||
depth,
|
||||
default_child: None,
|
||||
default_target_ident: None,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
@@ -451,12 +462,11 @@ fn lower_body(
|
||||
}
|
||||
ir.states[state_id as usize].children = child_ids.clone();
|
||||
|
||||
if let Some((dc_ident, _)) = &body.default_child {
|
||||
let resolved = child_ids
|
||||
.iter()
|
||||
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
|
||||
.copied();
|
||||
ir.states[state_id as usize].default_child = resolved;
|
||||
// Stash the unresolved default target. The actual id is filled in by
|
||||
// `resolve_transitions`, which has the global state-name → id map and
|
||||
// can resolve targets that aren't direct children.
|
||||
if let Some((dc_ident, dc_span)) = &body.default_child {
|
||||
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -547,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve default-transition targets against the same global map.
|
||||
for i in 0..n {
|
||||
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
|
||||
let tname = ident.to_string();
|
||||
match name_map.get(&tname).copied() {
|
||||
Some(id) => {
|
||||
ir.states[i].default_child = Some(id);
|
||||
}
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
format!("unknown state `{}` in `default(...)`", ident),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+76
-34
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
|
||||
use crate::parse::{StateBody, StatechartInput};
|
||||
|
||||
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
validate_default_graph(ir)?;
|
||||
// Duplicate state names
|
||||
let mut seen = HashMap::new();
|
||||
for s in &ir.states {
|
||||
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Children require default; default must be a direct child; no default allowed if no children.
|
||||
if !s.children.is_empty() && s.default_child.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
s.span,
|
||||
format!(
|
||||
"state `{}` has children but no `default` declaration",
|
||||
s.name
|
||||
),
|
||||
));
|
||||
}
|
||||
// `default` is optional: a composite without a `default(...)` is a
|
||||
// valid resting state — transitions targeting it land on the composite
|
||||
// itself, with no descent. Substates of such a composite are reachable
|
||||
// only via explicit transitions. (`default` must still be a direct
|
||||
// child when declared; that's checked in `validate_parse_tree`.)
|
||||
|
||||
// duplicate transitions per trigger in this state, AND transition
|
||||
// target (if present) must be declared after every action handler
|
||||
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the default-edge graph and reject cycles. Each state has at most one
|
||||
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
|
||||
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
|
||||
///
|
||||
/// Cycles are rejected at compile time because at runtime, a default chain
|
||||
/// fires as a sequence of immediate transitions on entry — a cycle would loop
|
||||
/// forever, never returning control to the dispatcher.
|
||||
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
|
||||
let n = ir.states.len();
|
||||
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
|
||||
let mut color = vec![0u8; n];
|
||||
for start in 0..n {
|
||||
if color[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
if color[cur] == 2 {
|
||||
// Joined a previously-cleared subgraph. Chain so far is fine.
|
||||
break;
|
||||
}
|
||||
if color[cur] == 1 {
|
||||
// Back-edge into the current chain — cycle.
|
||||
let cycle_start = chain
|
||||
.iter()
|
||||
.position(|&x| x == cur)
|
||||
.expect("gray node must be on current chain");
|
||||
let cycle: Vec<String> = chain[cycle_start..]
|
||||
.iter()
|
||||
.map(|&id| ir.states[id].name.to_string())
|
||||
.collect();
|
||||
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
|
||||
let err_span = ir.states[chain[cycle_start]]
|
||||
.default_target_ident
|
||||
.as_ref()
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap_or(ir.states[chain[cycle_start]].span);
|
||||
return Err(syn::Error::new(
|
||||
err_span,
|
||||
format!(
|
||||
"default-transition cycle detected: {} \
|
||||
(entering any state in this cycle would loop forever \
|
||||
before reaching user code)",
|
||||
pretty
|
||||
),
|
||||
));
|
||||
}
|
||||
color[cur] = 1;
|
||||
chain.push(cur);
|
||||
match ir.states[cur].default_child {
|
||||
Some(next) => cur = next as usize,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for &id in &chain {
|
||||
color[id] = 2;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additional checks that require the original parse tree (for counting duplicate
|
||||
/// `default`/`terminate` with their spans, and the "default in childless state" /
|
||||
/// "default not a direct child" / "terminate in non-root" errors).
|
||||
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
|
||||
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
||||
let root_name = input.name.to_string();
|
||||
validate_body(&input.body, true, &root_name)
|
||||
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
|
||||
));
|
||||
}
|
||||
}
|
||||
if body.default_child.is_some() && body.children.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
body.default_span.unwrap(),
|
||||
format!(
|
||||
"`default` in state `{}` which has no child states",
|
||||
state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
|
||||
// default must be a direct child
|
||||
let is_child = body.children.iter().any(|c| c.name == *dc);
|
||||
if !is_child {
|
||||
return Err(syn::Error::new(
|
||||
*dspan,
|
||||
format!(
|
||||
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
|
||||
dc, state_name, dc, state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// default(...) is a transition that fires immediately after the
|
||||
// declaring state's entries finish. It may be declared on any state
|
||||
// (composite or leaf) and may target any state in the chart. A leaf
|
||||
// with `default(T)` just transitions to T on every entry. The
|
||||
// default-graph cycle check in `validate_default_graph` (run from the
|
||||
// top-level `validate_parse_tree` over the lowered IR) ensures we
|
||||
// cannot construct an infinite chain at startup.
|
||||
for c in &body.children {
|
||||
let cname = c.name.to_string();
|
||||
validate_body(&c.body, false, &cname)?;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
state ChildA { entry: e; }
|
||||
state ChildB { entry: e; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: state `Parent` has children but no `default` declaration
|
||||
--> tests/ui/t9_2_missing_default.rs:13:11
|
||||
|
|
||||
13 | state Parent {
|
||||
| ^^^^^^
|
||||
@@ -1,25 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
default(Cousin);
|
||||
state Child { entry: e; }
|
||||
}
|
||||
state Cousin { entry: e; }
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
|
||||
--> tests/ui/t9_4_default_non_child.rs:14:9
|
||||
|
|
||||
14 | default(Cousin);
|
||||
| ^^^^^^^
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
// `default(self)` is a 1-cycle: entering A would fire its default
|
||||
// transition to A, which would fire its default again, ad infinitum.
|
||||
// Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -9,9 +12,9 @@ statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Leaf);
|
||||
state Leaf {
|
||||
default(Ghost);
|
||||
default(A);
|
||||
state A {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
||||
|
|
||||
17 | default(A);
|
||||
| ^^^^^^^
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default` in state `Leaf` which has no child states
|
||||
--> tests/ui/t9_5_default_childless.rs:14:9
|
||||
|
|
||||
14 | default(Ghost);
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,30 @@
|
||||
// A → B → A is a default cycle: entering either state would loop
|
||||
// forever before reaching user code. Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(A);
|
||||
state A {
|
||||
default(B);
|
||||
entry: e;
|
||||
}
|
||||
state B {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
||||
|
|
||||
16 | default(B);
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Compile-pass: a composite state may omit `default(...)`.
|
||||
//!
|
||||
//! When a composite has children but no `default`, transitions targeting
|
||||
//! that composite land on the composite itself — the substates are reachable
|
||||
//! only via explicit transitions. Microwave-style chart: `Standby` is the
|
||||
//! resting state and `LowPower` is a sub-mode you enter only on demand.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
EnterLowPower,
|
||||
LeaveLowPower,
|
||||
Halt,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
Microwave {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Standby);
|
||||
terminate(Halt);
|
||||
|
||||
state Standby {
|
||||
// No `default(...)` — Standby is itself the resting state.
|
||||
on(PowerOn) => On;
|
||||
on(EnterLowPower) => LowPower;
|
||||
|
||||
state LowPower {
|
||||
on(LeaveLowPower) => Standby;
|
||||
}
|
||||
}
|
||||
|
||||
state On {
|
||||
on(PowerOff) => Standby;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrowaveActions for MicrowaveActionContext<'_> {}
|
||||
|
||||
fn main() {
|
||||
let _ = Microwave::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Compile-pass: a state's `default(...)` may target an ancestor. Entering
|
||||
//! the declaring state would immediately exit back up to the ancestor (the
|
||||
//! up-transition rule applies — ancestor not re-entered). The state is
|
||||
//! reached only via an explicit transition; without one, control never
|
||||
//! enters it. (No cycle here: Outer has no default; Inner targets Outer.)
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { GoIn, Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Outer);
|
||||
terminate(Halt);
|
||||
|
||||
state Outer {
|
||||
entry: outer_in;
|
||||
on(GoIn) => Inner;
|
||||
state Inner {
|
||||
entry: inner_in;
|
||||
// Default jumps up to Outer. Inner is reachable only via the
|
||||
// explicit GoIn transition; once entered, this default fires
|
||||
// and lands the chart back at Outer (no re-entry of Outer).
|
||||
default(Outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn outer_in(&mut self) {}
|
||||
fn inner_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Compile-pass: a state's `default(...)` may target a sibling. Entering
|
||||
//! the declaring state immediately fires a transition out to the sibling,
|
||||
//! using the same LCA-aware algorithm as any other transition.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Foyer);
|
||||
terminate(Halt);
|
||||
|
||||
state Foyer {
|
||||
// Default redirects out of Foyer to its sibling LivingRoom.
|
||||
default(LivingRoom);
|
||||
entry: foyer_in;
|
||||
}
|
||||
state LivingRoom {
|
||||
entry: living_in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn foyer_in(&mut self) {}
|
||||
fn living_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
+1
-5
@@ -11,7 +11,6 @@ readme = "../README.md"
|
||||
keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"]
|
||||
categories = ["embedded", "no-std", "asynchronous", "rust-patterns"]
|
||||
autoexamples = false
|
||||
exclude = ["examples/microwave_tui.rs"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -40,7 +39,7 @@ trace-tracing = ["dep:tracing"]
|
||||
journal = []
|
||||
|
||||
[dependencies]
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" }
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.5.1" }
|
||||
heapless = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
||||
embassy-sync = { version = "0.8", optional = true }
|
||||
@@ -53,9 +52,6 @@ tracing = { version = "0.1", default-features = false, optional = true }
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies]
|
||||
trybuild = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
|
||||
ratatui = "0.28"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
futures = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
iai-callgrind = "0.16"
|
||||
|
||||
File renamed without changes.
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
@@ -1,761 +0,0 @@
|
||||
//! Interactive microwave oven TUI driven by `hsmc`.
|
||||
//!
|
||||
//! Run it:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example microwave_tui --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! Keys:
|
||||
//! S — Start cook O — Open door
|
||||
//! X — Stop / cancel C — Close door
|
||||
//! K — Simulate power failure (terminates the machine)
|
||||
//! Q — Quit
|
||||
//!
|
||||
//! This demonstrates the same statechart as `microwave.rs` but hooked up to
|
||||
//! live keyboard input and a real-time ratatui UI. The state machine runs
|
||||
//! on `oven.run()` in one task; a second task reads keys and forwards them
|
||||
//! via a cloned `sender()`; a third task redraws the screen 30×/sec using
|
||||
//! state published by entry/exit actions.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use hsmc::{statechart, Duration};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
|
||||
Terminal,
|
||||
};
|
||||
|
||||
// ---------- Shared UI state ----------
|
||||
//
|
||||
// The actions write into this through the user context. The render task
|
||||
// reads it. `Arc<Mutex<_>>` keeps it cheap and thread-safe.
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UiState {
|
||||
state_path: Vec<&'static str>,
|
||||
display: String,
|
||||
magnetron: bool,
|
||||
heater: bool,
|
||||
door_open: bool,
|
||||
blink: bool,
|
||||
log: VecDeque<String>,
|
||||
/// Instant at which the cook should complete. Advances by the pause
|
||||
/// duration on resume so the progress bar freezes while paused.
|
||||
cook_deadline: Option<Instant>,
|
||||
cook_total: Duration,
|
||||
/// Set when we enter Paused; cleared on resume. Freezes the gauge.
|
||||
paused_at: Option<Instant>,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
async fn log(&mut self, msg: impl Into<String>) {
|
||||
self.log.push_back(msg.into());
|
||||
while self.log.len() > 50 {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
async fn set_path(&mut self, path: &[&'static str]) {
|
||||
self.state_path = path.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- User context ----------
|
||||
|
||||
pub struct OvenContext {
|
||||
pub ui: Arc<Mutex<UiState>>,
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OvenEvent {
|
||||
StartPressed,
|
||||
StopPressed,
|
||||
DoorOpened,
|
||||
DoorClosed,
|
||||
Reset,
|
||||
/// Sent by the external watchdog when the adjusted cook deadline is met.
|
||||
/// Using an event instead of a static `Duration` transition lets us pause
|
||||
/// and resume the cook clock — something hsmc timers can't do on their own.
|
||||
CookDone,
|
||||
PowerFailure,
|
||||
}
|
||||
|
||||
// ---------- Statechart ----------
|
||||
//
|
||||
// Same shape as microwave.rs (every feature exercised) with a longer cook
|
||||
// time so the user has time to interact. The cook timer lives on `Running`
|
||||
// so it survives Heating⇄Paused transitions.
|
||||
|
||||
const COOK_MS: u64 = 8_000;
|
||||
|
||||
statechart! {
|
||||
Oven {
|
||||
context: OvenContext;
|
||||
events: OvenEvent;
|
||||
|
||||
entry: boot_self_test;
|
||||
entry: log_boot_done;
|
||||
exit: save_last_state;
|
||||
exit: cut_all_power;
|
||||
default(Idle);
|
||||
terminate(PowerFailure);
|
||||
|
||||
// Root-level door tracking. Any state whose local handler shadows these
|
||||
// must repeat `mark_door_*` alongside its own handlers.
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
|
||||
// Idle is split so Start can only fire when the door is closed. This is
|
||||
// the HSM equivalent of a guard — a structural check rather than a
|
||||
// boolean predicate.
|
||||
state Idle {
|
||||
// `sync_door` runs after the default descent completes handling, then
|
||||
// emits DoorOpened if the door is physically open. That re-aligns the
|
||||
// machine with reality when we land in Idle from Running (e.g. Stop
|
||||
// pressed while door is open). The emitted event is processed after
|
||||
// the entry cascade, so DoorClosed → DoorOpen happens cleanly.
|
||||
entry: enter_idle, sync_door;
|
||||
default(DoorClosed);
|
||||
|
||||
state DoorClosed {
|
||||
entry: show_ready;
|
||||
// Local handlers, so we must repeat mark_door_open here.
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => DoorOpen;
|
||||
|
||||
on(StartPressed) => record_start;
|
||||
on(StartPressed) => prime_cook;
|
||||
on(StartPressed) => Running;
|
||||
}
|
||||
|
||||
state DoorOpen {
|
||||
entry: show_close_door;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
on(DoorClosed) => DoorClosed;
|
||||
// Pressing Start while door is open: beep + log, no transition.
|
||||
on(StartPressed) => warn_door_open;
|
||||
}
|
||||
}
|
||||
|
||||
state Running {
|
||||
entry: enter_running, start_cook_clock;
|
||||
exit: stop_cook_clock;
|
||||
default(Cooking);
|
||||
|
||||
on(after Duration::from_millis(250)) => heartbeat;
|
||||
on(StopPressed) => Idle;
|
||||
on(CookDone) => Done;
|
||||
|
||||
state Cooking {
|
||||
entry: enter_cooking, show_cooking;
|
||||
default(Heating);
|
||||
|
||||
state Heating {
|
||||
// Magnetron + heater live on Heating so they turn off the
|
||||
// instant we leave for Paused.
|
||||
entry: enter_heating, start_magnetron, heater_on;
|
||||
exit: heater_off, stop_magnetron;
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => pause_cook_clock;
|
||||
on(DoorOpened) => Suspended;
|
||||
}
|
||||
|
||||
// Suspended = "cook is paused". Two substates express the classic
|
||||
// microwave rule: closing the door alone does NOT resume cooking
|
||||
// — the user must press Start. Pressing Stop (handled by Running)
|
||||
// cancels entirely back to Idle.
|
||||
state Suspended {
|
||||
entry: enter_suspended, start_pause_blink;
|
||||
exit: stop_pause_blink;
|
||||
default(DoorAjar);
|
||||
|
||||
state DoorAjar {
|
||||
entry: show_paused;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
on(DoorClosed) => AwaitingResume;
|
||||
}
|
||||
|
||||
state AwaitingResume {
|
||||
entry: show_press_start;
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => DoorAjar;
|
||||
|
||||
// Actions fire before the transition, so the clock is
|
||||
// resumed and the log line written before we re-enter
|
||||
// Heating (which turns the magnetron back on).
|
||||
on(StartPressed) => log_resume;
|
||||
on(StartPressed) => resume_cook_clock;
|
||||
on(StartPressed) => Heating;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state Done {
|
||||
entry: enter_done, beep, show_done;
|
||||
exit: exit_done;
|
||||
|
||||
on(after Duration::from_millis(2000)) => schedule_auto_reset;
|
||||
on(StartPressed) => Done;
|
||||
on(Reset) => Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Actions ----------
|
||||
//
|
||||
// Every method is either publishing to the shared UI state or emitting an
|
||||
// event back into the machine.
|
||||
|
||||
impl OvenActions for OvenActionContext<'_> {
|
||||
// Root
|
||||
async fn boot_self_test(&mut self) { self.ui.lock().unwrap().log("BOOT self-test ok"); }
|
||||
async fn log_boot_done(&mut self) { self.ui.lock().unwrap().log("BOOT ready"); }
|
||||
async fn save_last_state(&mut self){ self.ui.lock().unwrap().log("SHUTDOWN persisted state"); }
|
||||
async fn cut_all_power(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = false;
|
||||
ui.heater = false;
|
||||
ui.display = "-- OFF --".into();
|
||||
ui.terminated = true;
|
||||
ui.log("SHUTDOWN power cut");
|
||||
}
|
||||
|
||||
// Door tracking (root-level)
|
||||
async fn mark_door_open(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.door_open = true;
|
||||
}
|
||||
async fn mark_door_closed(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.door_open = false;
|
||||
}
|
||||
|
||||
// Idle
|
||||
async fn enter_idle(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle"]);
|
||||
}
|
||||
async fn sync_door(&mut self) {
|
||||
let door_open = self.ui.lock().unwrap().door_open;
|
||||
if door_open {
|
||||
self.ui.lock().unwrap().log("IDLE door still open — syncing");
|
||||
let _ = self.emit(OvenEvent::DoorOpened);
|
||||
}
|
||||
}
|
||||
async fn show_ready(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle", "DoorClosed"]);
|
||||
ui.display = "READY".into();
|
||||
ui.log("IDLE ready");
|
||||
}
|
||||
async fn show_close_door(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle", "DoorOpen"]);
|
||||
ui.display = "CLOSE DOOR".into();
|
||||
ui.log("IDLE door is open");
|
||||
}
|
||||
async fn warn_door_open(&mut self) {
|
||||
self.ui.lock().unwrap().log("WARN can't start — door open");
|
||||
}
|
||||
async fn record_start(&mut self) { self.ui.lock().unwrap().log("BTN start pressed"); }
|
||||
async fn prime_cook(&mut self) { self.ui.lock().unwrap().log("BTN primed cook"); }
|
||||
async fn beep(&mut self) { self.ui.lock().unwrap().log("*BEEP*"); }
|
||||
|
||||
// Running
|
||||
async fn enter_running(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running"]);
|
||||
}
|
||||
async fn start_magnetron(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = true;
|
||||
ui.log("MAG on");
|
||||
}
|
||||
async fn stop_magnetron(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = false;
|
||||
ui.log("MAG off");
|
||||
}
|
||||
async fn start_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
let total = Duration::from_millis(COOK_MS);
|
||||
ui.cook_total = total;
|
||||
ui.cook_deadline = Some(Instant::now() + total);
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn stop_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.cook_deadline = None;
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn pause_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.paused_at = Some(Instant::now());
|
||||
ui.log("CLOCK paused");
|
||||
}
|
||||
async fn resume_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
if let (Some(paused_at), Some(deadline)) = (ui.paused_at, ui.cook_deadline) {
|
||||
let skipped = paused_at.elapsed();
|
||||
ui.cook_deadline = Some(deadline + skipped);
|
||||
ui.log(format!("CLOCK resume (+{}ms)", skipped.as_millis()));
|
||||
}
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn heartbeat(&mut self) { /* UI gauge animates on its own */ }
|
||||
|
||||
// Cooking / Heating
|
||||
async fn enter_cooking(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking"]);
|
||||
}
|
||||
async fn show_cooking(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.display = "COOKING".into();
|
||||
ui.blink = false;
|
||||
}
|
||||
async fn enter_heating(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Heating"]);
|
||||
ui.blink = false;
|
||||
ui.display = "COOKING".into();
|
||||
}
|
||||
async fn heater_on(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.heater = true;
|
||||
ui.log("HEAT on");
|
||||
}
|
||||
async fn heater_off(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.heater = false;
|
||||
ui.log("HEAT off");
|
||||
}
|
||||
|
||||
// Suspended (paused)
|
||||
async fn enter_suspended(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended"]);
|
||||
}
|
||||
async fn show_paused(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "DoorAjar"]);
|
||||
ui.display = "PAUSED".into();
|
||||
}
|
||||
async fn show_press_start(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "AwaitingResume"]);
|
||||
ui.display = "PRESS START".into();
|
||||
ui.log("PAUSE door closed — press Start to resume");
|
||||
}
|
||||
async fn start_pause_blink(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.blink = true;
|
||||
ui.log("PAUSE blink on");
|
||||
}
|
||||
async fn stop_pause_blink(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.blink = false;
|
||||
ui.log("PAUSE blink off");
|
||||
}
|
||||
async fn log_resume(&mut self) { self.ui.lock().unwrap().log("PAUSE resuming"); }
|
||||
|
||||
// Done
|
||||
async fn enter_done(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Done"]);
|
||||
}
|
||||
async fn show_done(&mut self) {
|
||||
self.ui.lock().unwrap().display = "DONE!".into();
|
||||
}
|
||||
async fn exit_done(&mut self) { self.ui.lock().unwrap().log("DONE exit"); }
|
||||
async fn schedule_auto_reset(&mut self) {
|
||||
self.ui.lock().unwrap().log("DONE auto-reset queued");
|
||||
self.emit(OvenEvent::Reset).expect("queue has room");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Rendering ----------
|
||||
|
||||
fn draw(frame: &mut ratatui::Frame<'_>, ui: &UiState) {
|
||||
let size = frame.area();
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(16),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// --- Title bar ---
|
||||
let title = if ui.terminated {
|
||||
Line::from(vec![
|
||||
Span::styled(" MICROWAVE ", Style::new().bg(Color::Red).fg(Color::White).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled("TERMINATED", Style::new().fg(Color::Red).bold()),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled(" MICROWAVE ", Style::new().bg(Color::Yellow).fg(Color::Black).bold()),
|
||||
Span::raw(" hsmc statechart demo · press Q to quit"),
|
||||
])
|
||||
};
|
||||
frame.render_widget(Paragraph::new(title), root[0]);
|
||||
|
||||
// --- Main area: oven on the left, status on the right ---
|
||||
let main = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(root[1]);
|
||||
|
||||
draw_oven(frame, main[0], ui);
|
||||
draw_status(frame, main[1], ui);
|
||||
|
||||
// --- Log ---
|
||||
let log_lines: Vec<Line> = ui.log.iter().rev().take(8).rev()
|
||||
.map(|s| Line::from(s.as_str()))
|
||||
.collect();
|
||||
let log = Paragraph::new(log_lines)
|
||||
.block(Block::default().title(" Event log ").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(log, root[2]);
|
||||
|
||||
// --- Keys ---
|
||||
let keys = Line::from(vec![
|
||||
key_hint("S", "Start"),
|
||||
Span::raw(" "),
|
||||
key_hint("X", "Stop"),
|
||||
Span::raw(" "),
|
||||
key_hint("O", "Open door"),
|
||||
Span::raw(" "),
|
||||
key_hint("C", "Close door"),
|
||||
Span::raw(" "),
|
||||
key_hint("K", "Kill (power fail)"),
|
||||
Span::raw(" "),
|
||||
key_hint("Q", "Quit"),
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(keys).block(Block::default().borders(Borders::ALL).title(" Controls ")),
|
||||
root[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn key_hint(k: &str, label: &str) -> Span<'static> {
|
||||
Span::from(format!("[{}] {}", k, label)).fg(Color::Cyan)
|
||||
}
|
||||
|
||||
fn draw_oven(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Oven ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Door indicator line
|
||||
let door_line = if ui.door_open {
|
||||
Span::styled(" ┌───── DOOR OPEN ─────┐", Style::new().fg(Color::Yellow).bold())
|
||||
} else {
|
||||
Span::styled(" ┌───── door closed ─────┐", Style::new().fg(Color::DarkGray))
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(door_line)), rows[0]);
|
||||
|
||||
// Big display
|
||||
let display_style = if ui.terminated {
|
||||
Style::new().fg(Color::Red).bold()
|
||||
} else if ui.blink && (Instant::now().elapsed().as_millis() / 300) % 2 == 0 {
|
||||
Style::new().fg(Color::Yellow).bold()
|
||||
} else if ui.display == "DONE!" {
|
||||
Style::new().fg(Color::Green).bold()
|
||||
} else if ui.display == "COOKING" {
|
||||
Style::new().fg(Color::LightRed).bold()
|
||||
} else {
|
||||
Style::new().fg(Color::Cyan).bold()
|
||||
};
|
||||
let big = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" ┃ {:^14} ┃", ui.display),
|
||||
display_style,
|
||||
)),
|
||||
Line::from(Span::styled(" ┃ ┃", Style::new().fg(Color::DarkGray))),
|
||||
Line::from(Span::styled(" ┗━━━━━━━━━━━━━━━━━━┛", Style::new().fg(Color::DarkGray))),
|
||||
]);
|
||||
frame.render_widget(big, rows[1]);
|
||||
|
||||
// Progress gauge for cook — freezes while paused.
|
||||
let (ratio, label, color) = if let Some(deadline) = ui.cook_deadline {
|
||||
let now = ui.paused_at.unwrap_or_else(Instant::now);
|
||||
let total = ui.cook_total;
|
||||
let remaining = deadline.saturating_duration_since(now);
|
||||
let elapsed = total.saturating_sub(remaining);
|
||||
let r = (elapsed.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0);
|
||||
let label = format!("{:.1}s / {:.1}s{}",
|
||||
elapsed.as_secs_f64(),
|
||||
total.as_secs_f64(),
|
||||
if ui.paused_at.is_some() { " (paused)" } else { "" },
|
||||
);
|
||||
let color = if ui.paused_at.is_some() { Color::Yellow } else { Color::LightRed };
|
||||
(r, label, color)
|
||||
} else {
|
||||
(0.0, "—".to_string(), Color::DarkGray)
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.gauge_style(Style::new().fg(color))
|
||||
.ratio(ratio)
|
||||
.label(label);
|
||||
frame.render_widget(gauge, rows[3]);
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Status ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let path = if ui.state_path.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
ui.state_path.join(" › ")
|
||||
};
|
||||
|
||||
let bullet = |on: bool| {
|
||||
if on { Span::styled("●", Style::new().fg(Color::Green).bold()) }
|
||||
else { Span::styled("○", Style::new().fg(Color::DarkGray)) }
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" State path: ", Style::new().fg(Color::DarkGray)),
|
||||
Span::styled(path, Style::new().fg(Color::White).bold()),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::raw(" Magnetron "),
|
||||
bullet(ui.magnetron),
|
||||
Span::raw(if ui.magnetron { " ON " } else { " off" }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Heater "),
|
||||
bullet(ui.heater),
|
||||
Span::raw(if ui.heater { " ON " } else { " off" }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Door "),
|
||||
if ui.door_open {
|
||||
Span::styled("OPEN ", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD))
|
||||
} else {
|
||||
Span::styled("CLOSED", Style::new().fg(Color::DarkGray))
|
||||
},
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" hsmc tip: ", Style::new().fg(Color::DarkGray)),
|
||||
Span::raw("events bubble · timers don't"),
|
||||
]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> io::Result<()> {
|
||||
// Shared UI state.
|
||||
let ui = Arc::new(Mutex::new(UiState::default()));
|
||||
|
||||
// Build the machine.
|
||||
let ctx = OvenContext { ui: Arc::clone(&ui) };
|
||||
let mut oven = Oven::with_queue_capacity::<16>(ctx);
|
||||
let sender = oven.sender();
|
||||
let fault_sender = oven.sender();
|
||||
let cook_sender = oven.sender();
|
||||
|
||||
// Raw-mode terminal.
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Render loop — 30 fps.
|
||||
let ui_for_render = Arc::clone(&ui);
|
||||
let (render_stop_tx, mut render_stop_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let render_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut render_stop_rx => break,
|
||||
_ = interval.tick() => {
|
||||
let snapshot = {
|
||||
let g = ui_for_render.lock().unwrap();
|
||||
UiSnapshot::from(&*g)
|
||||
};
|
||||
let _ = terminal.draw(|f| draw(f, &snapshot.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Restore terminal.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
terminal
|
||||
});
|
||||
|
||||
// Cook-clock watchdog. Polls shared UI state; when we're not paused and
|
||||
// the deadline has passed, fires a single CookDone event and then sleeps
|
||||
// until the deadline is re-armed. The machine discards CookDone if the
|
||||
// current state doesn't handle it (e.g. after StopPressed).
|
||||
let ui_for_watchdog = Arc::clone(&ui);
|
||||
let watchdog_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let should_fire = {
|
||||
let u = ui_for_watchdog.lock().unwrap();
|
||||
if u.terminated { return; }
|
||||
match (u.cook_deadline, u.paused_at) {
|
||||
(Some(deadline), None) => Instant::now() >= deadline,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if should_fire {
|
||||
let _ = cook_sender.send(OvenEvent::CookDone);
|
||||
// Give the machine a beat to process and clear cook_deadline
|
||||
// (via Running's exit → stop_cook_clock) before we check again.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Input loop.
|
||||
let ui_for_input = Arc::clone(&ui);
|
||||
let input_task = tokio::spawn(async move {
|
||||
let mut events = EventStream::new();
|
||||
while let Some(Ok(ev)) = events.next().await {
|
||||
if let Event::Key(k) = ev {
|
||||
if k.kind != KeyEventKind::Press { continue; }
|
||||
let code = k.code;
|
||||
let msg = match code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => Some(OvenEvent::StartPressed),
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => Some(OvenEvent::StopPressed),
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => Some(OvenEvent::DoorOpened),
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => Some(OvenEvent::DoorClosed),
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => Some(OvenEvent::PowerFailure),
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
|
||||
// Quit: cleanly terminate the machine via PowerFailure.
|
||||
let _ = fault_sender.send(OvenEvent::PowerFailure);
|
||||
break;
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(m) = msg {
|
||||
ui_for_input.lock().unwrap().log(format!("KEY -> {:?}", m));
|
||||
let _ = sender.send(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Drive the state machine on the main task.
|
||||
let _ = oven.run().await;
|
||||
|
||||
// Let the UI show the TERMINATED banner briefly.
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
|
||||
// Stop the render loop.
|
||||
let _ = render_stop_tx.send(());
|
||||
input_task.abort();
|
||||
watchdog_task.abort();
|
||||
let mut terminal = render_task.await.expect("render task");
|
||||
|
||||
// Belt-and-braces teardown in case the render loop didn't get to it.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
|
||||
// Final transcript.
|
||||
let ui = ui.lock().unwrap();
|
||||
println!("\nFinal state: {}", ui.state_path.join(" › "));
|
||||
println!("Events: {}", ui.log.len());
|
||||
for line in ui.log.iter() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Snapshot so we don't hold the Mutex across the render.
|
||||
struct UiSnapshot {
|
||||
state_path: Vec<&'static str>,
|
||||
display: String,
|
||||
magnetron: bool,
|
||||
heater: bool,
|
||||
door_open: bool,
|
||||
blink: bool,
|
||||
log: VecDeque<String>,
|
||||
cook_deadline: Option<Instant>,
|
||||
cook_total: Duration,
|
||||
paused_at: Option<Instant>,
|
||||
terminated: bool,
|
||||
}
|
||||
impl From<&UiState> for UiSnapshot {
|
||||
fn from(u: &UiState) -> Self {
|
||||
Self {
|
||||
state_path: u.state_path.clone(),
|
||||
display: u.display.clone(),
|
||||
magnetron: u.magnetron,
|
||||
heater: u.heater,
|
||||
door_open: u.door_open,
|
||||
blink: u.blink,
|
||||
log: u.log.clone(),
|
||||
cook_deadline: u.cook_deadline,
|
||||
cook_total: u.cook_total,
|
||||
paused_at: u.paused_at,
|
||||
terminated: u.terminated,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<UiSnapshot> for UiState {
|
||||
fn from(s: UiSnapshot) -> Self {
|
||||
Self {
|
||||
state_path: s.state_path,
|
||||
display: s.display,
|
||||
magnetron: s.magnetron,
|
||||
heater: s.heater,
|
||||
door_open: s.door_open,
|
||||
blink: s.blink,
|
||||
log: s.log,
|
||||
cook_deadline: s.cook_deadline,
|
||||
cook_total: s.cook_total,
|
||||
paused_at: s.paused_at,
|
||||
terminated: s.terminated,
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -56,8 +56,10 @@ pub enum TransitionReason {
|
||||
/// fired this transition.
|
||||
Timer { state: u16, timer: u16 },
|
||||
/// Driven from inside the runtime without an originating event or
|
||||
/// timer (e.g. default-descent into a child after entering a
|
||||
/// composite state). Reserved; not currently emitted.
|
||||
/// timer. Emitted for `default(...)` firings: after a state with a
|
||||
/// declared default is entered, the default fires as a transition
|
||||
/// with this reason. The default target may be any state in the
|
||||
/// chart — sibling, ancestor, anywhere.
|
||||
Internal,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` targeting an ANCESTOR.
|
||||
//! Inner's `default(Outer)` is a valid up-default. Inner is reachable
|
||||
//! only via an explicit transition. When entered, its default fires
|
||||
//! immediately, exiting Inner back to Outer (up-transition rule:
|
||||
//! Outer is not re-entered).
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
GoIn,
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
AncestorDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Outer);
|
||||
terminate(Halt);
|
||||
|
||||
state Outer {
|
||||
entry: outer_in;
|
||||
on(GoIn) => Inner;
|
||||
state Inner {
|
||||
entry: inner_in;
|
||||
exit: inner_out;
|
||||
// Inner's default redirects up to Outer. After entering
|
||||
// Inner, this default fires as an up-transition that
|
||||
// exits Inner. Outer is not re-entered.
|
||||
default(Outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AncestorDefaultActions for AncestorDefaultActionContext<'_> {
|
||||
async fn outer_in(&mut self) {}
|
||||
async fn inner_in(&mut self) {}
|
||||
async fn inner_out(&mut self) {}
|
||||
}
|
||||
|
||||
const AD_ROOT: u16 = 0;
|
||||
const AD_OUTER: u16 = 1;
|
||||
const AD_INNER: u16 = 2;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_lands_at_outer_no_default_descent() {
|
||||
// Outer has no default of its own — initial entry stops at Outer.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = AncestorDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
assert_eq!(m.current_state(), AncestorDefaultState::Outer);
|
||||
let j = m.take_journal();
|
||||
let entered: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![AD_ROOT, AD_OUTER]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_explicit_into_inner_bounces_back_via_up_default() {
|
||||
// `=> Inner` enters Inner (entries fire), then Inner's default fires
|
||||
// as an up-transition Inner → Outer. Outer is NOT re-entered (it
|
||||
// was already on the active path).
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = AncestorDefault::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoIn).await;
|
||||
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
AncestorDefaultState::Outer,
|
||||
"Inner's default bounces us back to Outer"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
|
||||
// Inner was entered (its entries fired) and then exited
|
||||
// (its default fired an up-transition).
|
||||
let entered_states: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Across the full journal: Root, Outer, Inner. After Inner's
|
||||
// up-default to Outer, Outer is NOT re-entered.
|
||||
assert_eq!(entered_states, vec![AD_ROOT, AD_OUTER, AD_INNER]);
|
||||
|
||||
let exited_states: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
exited_states,
|
||||
vec![AD_INNER],
|
||||
"only Inner exits; Outer remains on the active path"
|
||||
);
|
||||
|
||||
// Outer's entry actions ran exactly once (initial entry only).
|
||||
let outer_entries = j
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: AD_OUTER,
|
||||
kind: hsmc::ActionKind::Entry,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(outer_entries, 1, "Outer is entered once and stays put");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` chains. Hub → Spoke1 →
|
||||
//! Spoke2. Each link fires as a real LCA-aware transition. The
|
||||
//! compile-time default-graph cycle check guarantees this terminates.
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
ChainedDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Hub);
|
||||
terminate(Halt);
|
||||
|
||||
state Hub {
|
||||
entry: hub_in;
|
||||
default(Spoke1);
|
||||
}
|
||||
state Spoke1 {
|
||||
entry: s1_in;
|
||||
exit: s1_out;
|
||||
default(Spoke2);
|
||||
}
|
||||
state Spoke2 {
|
||||
entry: s2_in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChainedDefaultActions for ChainedDefaultActionContext<'_> {
|
||||
async fn hub_in(&mut self) {}
|
||||
async fn s1_in(&mut self) {}
|
||||
async fn s1_out(&mut self) {}
|
||||
async fn s2_in(&mut self) {}
|
||||
}
|
||||
|
||||
const CD_ROOT: u16 = 0;
|
||||
const CD_HUB: u16 = 1;
|
||||
const CD_SPOKE1: u16 = 2;
|
||||
const CD_SPOKE2: u16 = 3;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_chained_defaults_terminate_at_chain_end() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = ChainedDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
assert_eq!(m.current_state(), ChainedDefaultState::Spoke2);
|
||||
|
||||
let j = m.take_journal();
|
||||
let entered: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![CD_ROOT, CD_HUB, CD_SPOKE1, CD_SPOKE2]);
|
||||
|
||||
let exited: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Each default fires as a sibling-transition: Hub → Spoke1
|
||||
// (exits Hub), then Spoke1 → Spoke2 (exits Spoke1).
|
||||
assert_eq!(exited, vec![CD_HUB, CD_SPOKE1]);
|
||||
|
||||
// At least 2 TransitionFired events for the chained defaults.
|
||||
let txn_count = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.count();
|
||||
assert!(txn_count >= 2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` targeting a SIBLING fires as
|
||||
//! a real LCA-aware transition immediately after the declaring state's
|
||||
//! entries finish. Foyer's `default(LivingRoom)` means: enter Foyer,
|
||||
//! run its entries, then immediately transition Foyer → LivingRoom
|
||||
//! (LCA = Root, exits Foyer, enters LivingRoom).
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
SiblingDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Foyer);
|
||||
terminate(Halt);
|
||||
|
||||
state Foyer {
|
||||
entry: foyer_in;
|
||||
exit: foyer_out;
|
||||
default(LivingRoom);
|
||||
}
|
||||
state LivingRoom {
|
||||
entry: living_in;
|
||||
exit: living_out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SiblingDefaultActions for SiblingDefaultActionContext<'_> {
|
||||
async fn foyer_in(&mut self) {}
|
||||
async fn foyer_out(&mut self) {}
|
||||
async fn living_in(&mut self) {}
|
||||
async fn living_out(&mut self) {}
|
||||
}
|
||||
|
||||
const SD_ROOT: u16 = 0;
|
||||
const SD_FOYER: u16 = 1;
|
||||
const SD_LIVING: u16 = 2;
|
||||
|
||||
fn entered(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn exited(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_sibling_default_exits_declarer_lands_at_target() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = SiblingDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
|
||||
assert_eq!(m.current_state(), SiblingDefaultState::LivingRoom);
|
||||
|
||||
let j = m.take_journal();
|
||||
assert_eq!(
|
||||
entered(&j),
|
||||
vec![SD_ROOT, SD_FOYER, SD_LIVING],
|
||||
"Root → Foyer → (default fires) → LivingRoom"
|
||||
);
|
||||
assert_eq!(
|
||||
exited(&j),
|
||||
vec![SD_FOYER],
|
||||
"Foyer is exited as the default transition's `from` state"
|
||||
);
|
||||
|
||||
// Foyer's entry must run before its exit (default fires after entries).
|
||||
let action_seq: Vec<(u16, ActionKind)> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state, kind, .. } => Some((*state, *kind)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let foyer_entry_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Entry)
|
||||
.unwrap();
|
||||
let foyer_exit_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Exit)
|
||||
.unwrap();
|
||||
let living_entry_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_LIVING && *k == ActionKind::Entry)
|
||||
.unwrap();
|
||||
assert!(foyer_entry_idx < foyer_exit_idx);
|
||||
assert!(foyer_exit_idx < living_entry_idx);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_sibling_default_emits_transition_observation() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = SiblingDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
let j = m.take_journal();
|
||||
let txn_count = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.count();
|
||||
assert!(
|
||||
txn_count >= 1,
|
||||
"expected at least one TransitionFired for the default-as-transition firing"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_target_with_three_default_levels() {
|
||||
// Idle → Tower: Tower has default chain L1 → L2 → L3.
|
||||
// Idle → Tower: Tower's `default(L1)` fires as a transition, L1's
|
||||
// `default(L2)` fires, L2's `default(L3)` fires. Each link is a
|
||||
// real transition with its own TransitionFired/Complete pair.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
// Root → Idle fires as Root's default.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SI },
|
||||
entry(SI, A_IDLE_IN),
|
||||
TraceEvent::Entered { state: SI },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SI,
|
||||
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
TraceEvent::EnterBegan { state: ST },
|
||||
entry(ST, A_TOWER_IN),
|
||||
TraceEvent::Entered { state: ST },
|
||||
// Tower's default(L1) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL1 },
|
||||
entry(SL1, A_L1_IN),
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
},
|
||||
// L1's default(L2) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL2 },
|
||||
entry(SL2, A_L2_IN),
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
},
|
||||
// L2's default(L3) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL3 },
|
||||
entry(SL3, A_L3_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SI),
|
||||
to: ST,
|
||||
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
|
||||
// After the GO event-driven transition: order of Entered
|
||||
// events should be Tower → L1 → L2 → L3. We split on the
|
||||
// event-driven TransitionFired specifically (initial-entry
|
||||
// defaults emit TransitionFired{Internal} too).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered: Vec<u16> = m.journal()[split..]
|
||||
.iter()
|
||||
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// Split on the event-driven TransitionFired (the initial
|
||||
// default-fire is also TransitionFired{Internal} but happens
|
||||
// before GO).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
|
||||
let actual = m.take_journal();
|
||||
let expected = vec![
|
||||
// Initial enter (driven by dispatch's prime-state branch).
|
||||
// Each `default(...)` is a real transition, so the journal
|
||||
// brackets every default-fire with TransitionFired
|
||||
// (Internal) / TransitionComplete pairs.
|
||||
TraceEvent::Started {
|
||||
chart_hash: Init4::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
// Halt.
|
||||
TraceEvent::EventReceived { event: E_HALT },
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
|
||||
@@ -140,7 +140,8 @@ fn handler(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial descent: Root → Parent → A.
|
||||
// Initial descent: Root → (Root's default fires) → Parent → (Parent's
|
||||
// default fires) → A. Each default-fire is a real transition.
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SP },
|
||||
entry(SP, A_P_IN),
|
||||
TraceEvent::Entered { state: SP },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -285,9 +304,19 @@ async fn det_lateral_target_with_children_default_descends() {
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
// D's `default(E)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SD,
|
||||
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
|
||||
let entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.skip_while(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests for OPTIONAL `default(...)` on composite states.
|
||||
//!
|
||||
//! Spec: a composite state may declare `default(...)` or omit it. When
|
||||
//! omitted, transitions targeting the composite land on the composite itself
|
||||
//! — no default-descent. Substates are reachable only via explicit
|
||||
//! transitions. This is the "microwave" pattern: a `Standby` state with
|
||||
//! sub-modes (e.g. `LowPower`) that the chart enters only on demand.
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
EnterLowPower,
|
||||
LeaveLowPower,
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
Microwave {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Standby);
|
||||
terminate(Halt);
|
||||
|
||||
state Standby {
|
||||
// No `default(...)` — Standby itself is the resting state.
|
||||
entry: standby_in;
|
||||
exit: standby_out;
|
||||
on(PowerOn) => On;
|
||||
on(EnterLowPower) => LowPower;
|
||||
|
||||
state LowPower {
|
||||
entry: low_in;
|
||||
exit: low_out;
|
||||
on(LeaveLowPower) => Standby;
|
||||
}
|
||||
}
|
||||
|
||||
state On {
|
||||
entry: on_in;
|
||||
exit: on_out;
|
||||
on(PowerOff) => Standby;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrowaveActions for MicrowaveActionContext<'_> {
|
||||
async fn standby_in(&mut self) {}
|
||||
async fn standby_out(&mut self) {}
|
||||
async fn low_in(&mut self) {}
|
||||
async fn low_out(&mut self) {}
|
||||
async fn on_in(&mut self) {}
|
||||
async fn on_out(&mut self) {}
|
||||
}
|
||||
|
||||
// State ids: 0=Root, 1=Standby, 2=LowPower, 3=On.
|
||||
const SR: u16 = 0;
|
||||
const S_STANDBY: u16 = 1;
|
||||
const S_LOW: u16 = 2;
|
||||
const S_ON: u16 = 3;
|
||||
|
||||
fn entered_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn exited_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entry_action_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
} => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_lands_on_composite_no_descent() {
|
||||
// Standby has children but no `default` — initial entry stops at Standby.
|
||||
// LowPower must NOT enter.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
// Drive initial entry without consuming an event.
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
MicrowaveState::Standby,
|
||||
"innermost active state is the composite Standby"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
let entered = entered_states(&j);
|
||||
assert_eq!(
|
||||
entered,
|
||||
vec![SR, S_STANDBY],
|
||||
"initial entry stops at Standby; no default-descent into LowPower"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_transition_into_substate_works() {
|
||||
// Explicit `=> LowPower` from Standby enters LowPower.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::EnterLowPower).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::LowPower);
|
||||
let j = m.take_journal();
|
||||
// Standby entered first (initial), then LowPower entered after the event.
|
||||
let entered = entered_states(&j);
|
||||
assert_eq!(entered, vec![SR, S_STANDBY, S_LOW]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_from_substate_lands_on_composite() {
|
||||
// From LowPower, `=> Standby` is an up-transition: exit LowPower, do not
|
||||
// re-enter Standby.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::EnterLowPower).await;
|
||||
let _ = m.dispatch(Ev::LeaveLowPower).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::Standby);
|
||||
|
||||
let j = m.take_journal();
|
||||
// Across the whole run: enter Standby, enter LowPower, exit
|
||||
// LowPower. Standby's entry must have run exactly once.
|
||||
let entry_states = entry_action_states(&j);
|
||||
let standby_entries = entry_states.iter().filter(|&&s| s == S_STANDBY).count();
|
||||
assert_eq!(
|
||||
standby_entries, 1,
|
||||
"Standby's entry runs once; up-transition does not re-enter it"
|
||||
);
|
||||
let exited = exited_states(&j);
|
||||
assert_eq!(
|
||||
exited,
|
||||
vec![S_LOW],
|
||||
"only LowPower is exited; Standby remains on the active path"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_to_composite_does_not_descend() {
|
||||
// From On, `=> Standby` lands on Standby — NOT on LowPower.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::PowerOn).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::On);
|
||||
let _ = m.dispatch(Ev::PowerOff).await;
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
MicrowaveState::Standby,
|
||||
"PowerOff lands on Standby; absence of `default` skips descent"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
// LowPower must never have been entered.
|
||||
let entered = entered_states(&j);
|
||||
assert!(
|
||||
!entered.contains(&S_LOW),
|
||||
"no descent into LowPower on entry to Standby"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_from_composite_unwinds_path() {
|
||||
// Halt while at Standby exits Standby → Root only.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let exits = exited_states(&j);
|
||||
assert_eq!(
|
||||
exits,
|
||||
vec![S_STANDBY, SR],
|
||||
"terminate from Standby exits Standby then Root"
|
||||
);
|
||||
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
|
||||
// Find the EVENT-driven TransitionFired (initial default-fires
|
||||
// are TransitionFired{Internal}; we want the GO transition).
|
||||
let trans_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let kinds: Vec<ActionKind> = j[trans_idx..]
|
||||
.iter()
|
||||
|
||||
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
// Root's `default(A)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
// A's `default(B)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_UPTOROOT },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let j = m.take_journal();
|
||||
// After the TransitionFired, no Entered event should fire.
|
||||
// After the EVENT-driven TransitionFired (the UpToRoot one),
|
||||
// no Entered event should fire — the up-transition exits B
|
||||
// and A but never re-enters root, and root has no default
|
||||
// (so no internal default-fire follows either).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered_after = j[split..]
|
||||
.iter()
|
||||
|
||||
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SM },
|
||||
entry(SM, A_M_IN),
|
||||
TraceEvent::Entered { state: SM },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
|
||||
+49
-1
@@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
// Each `default(...)` fires as a TransitionFired{Internal} /
|
||||
// TransitionComplete pair around the entered state.
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Up::<8>::CHART_HASH,
|
||||
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
|
||||
.rev()
|
||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||
.map(|(i, _)| i);
|
||||
// Find the EVENT-driven UpToB transition (initial defaults
|
||||
// emit TransitionFired{Internal} too).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// The last Entered event must be BEFORE the transition.
|
||||
assert!(
|
||||
|
||||
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
// Root's `default(Idle)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
},
|
||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
// Active's `default(Sub)` fires as an internal transition,
|
||||
// bracketed by its own TransitionFired/Complete.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_IDLE),
|
||||
to: ST_ACTIVE,
|
||||
|
||||
Reference in new issue
Block a user