Skip to main content

chartlet/spec/
state.rs

1use serde::{Deserialize, Serialize};
2
3use super::{
4    ChartSpec, Dash, DiagramOrientation, DiagramRole, DiagramSpacing, flow::validate_role,
5    is_false, sequence::is_identifier, validate_text,
6};
7use crate::error::{ChartError, ChartWarning};
8
9/// States of one diagram, and transitions between them; beyond this a layered layout stops being
10/// readable at a glance.
11pub(crate) const MAX_STATES: usize = 40;
12pub(crate) const MAX_TRANSITIONS: usize = 80;
13
14/// A state diagram: states joined by transitions, laid out like a flow chart.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct StateSpec {
18    pub states: Vec<StateNodeSpec>,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub transitions: Vec<TransitionSpec>,
21    /// The `id` of the state the machine starts in; it gets the initial dot and its arrow.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub initial: Option<String>,
24    /// The usual way through the states, as ids joined by transitions: kept in line and drawn
25    /// stronger.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub main_path: Vec<String>,
28    #[serde(default, skip_serializing_if = "DiagramOrientation::is_auto")]
29    pub orientation: DiagramOrientation,
30    #[serde(default, skip_serializing_if = "DiagramSpacing::is_normal")]
31    pub spacing: DiagramSpacing,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct StateNodeSpec {
37    pub id: String,
38    pub label: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub sublabel: Option<String>,
41    #[serde(default, skip_serializing_if = "StateKind::is_state")]
42    pub kind: StateKind,
43    /// A state the machine ends in, drawn with a double outline.
44    #[serde(default, rename = "final", skip_serializing_if = "is_false")]
45    pub is_final: bool,
46    /// The `id` of the composite state this state lies in.
47    #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
48    pub parent: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub role: Option<DiagramRole>,
51}
52
53/// What a state is.
54#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum StateKind {
57    /// A state the machine rests in: a box with well rounded corners.
58    #[default]
59    State,
60    /// A choice the machine passes through at once, by its guards: a diamond.
61    Choice,
62    /// A state made of states: a frame around the states that name it with `in`.
63    Composite,
64}
65
66impl StateKind {
67    // serde hands this function a reference, so the signature follows serde's shape.
68    #[allow(clippy::trivially_copy_pass_by_ref)]
69    const fn is_state(&self) -> bool {
70        matches!(self, Self::State)
71    }
72}
73
74/// A transition from one state to another, or to itself, written `event [guard] / action`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct TransitionSpec {
78    pub from: String,
79    pub to: String,
80    /// What triggers the transition.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub event: Option<String>,
83    /// The condition under which it is taken.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub guard: Option<String>,
86    /// What happens on the way.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub action: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub dash: Option<Dash>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub role: Option<DiagramRole>,
93}
94
95impl TransitionSpec {
96    /// The transition's label in the usual notation: `event [guard] / action`, each part only
97    /// where it is given; `None` without any.
98    pub(crate) fn label(&self) -> Option<String> {
99        let mut parts = Vec::new();
100        if let Some(event) = &self.event {
101            parts.push(event.clone());
102        }
103        if let Some(guard) = &self.guard {
104            parts.push(format!("[{guard}]"));
105        }
106        if let Some(action) = &self.action {
107            parts.push(format!("/ {action}"));
108        }
109        (!parts.is_empty()).then(|| parts.join(" "))
110    }
111}
112
113/// How deeply composite states may nest inside each other.
114pub(crate) const MAX_COMPOSITE_DEPTH: usize = 4;
115
116impl StateSpec {
117    /// Whether state `index` is a composite state, a frame rather than a state of its own.
118    pub(crate) fn is_composite(&self, index: usize) -> bool {
119        self.states[index].kind == StateKind::Composite
120    }
121
122    /// The composite states around state `index`, outermost first; `None` when they lie in each
123    /// other in a circle.
124    pub(crate) fn composites_around(&self, index: usize) -> Option<Vec<usize>> {
125        let mut chain = Vec::new();
126        let mut at = index;
127        while let Some(parent) = self.states[at].parent.as_deref() {
128            at = self.state(parent)?;
129            if chain.contains(&at) || at == index {
130                return None;
131            }
132            chain.push(at);
133        }
134        chain.reverse();
135        Some(chain)
136    }
137
138    /// The index of the state with `id`.
139    pub(crate) fn state(&self, id: &str) -> Option<usize> {
140        self.states.iter().position(|state| state.id == id)
141    }
142
143    /// Whether transition `index` joins two consecutive states of the main path.
144    pub(crate) fn on_main_path(&self, index: usize) -> bool {
145        let transition = &self.transitions[index];
146        self.main_path
147            .windows(2)
148            .any(|pair| pair[0] == transition.from && pair[1] == transition.to)
149    }
150}
151
152impl ChartSpec {
153    /// A state diagram: states with unique identifiers, transitions between them, an initial
154    /// state and a main path along transitions. A state nothing leads to, other than the initial
155    /// one, is reported as unreachable.
156    pub(super) fn validate_state(&self) -> Result<Vec<ChartWarning>, ChartError> {
157        self.reject_map_options("a state diagram", "state.states")?;
158        let Some(machine) = &self.state else {
159            return Err(ChartError::new(
160                "missing_state",
161                "/state",
162                "a state diagram requires a state block",
163            ));
164        };
165        machine.validate_states()?;
166        machine.validate_transitions()?;
167        machine.validate_initial_and_path()?;
168        let mut warnings = Vec::new();
169        if let Some(initial) = &machine.initial {
170            for (index, state) in machine.states.iter().enumerate() {
171                if machine.is_composite(index) {
172                    continue;
173                }
174                let reached = machine
175                    .transitions
176                    .iter()
177                    .any(|transition| transition.to == state.id && transition.from != state.id);
178                if !reached && &state.id != initial {
179                    warnings.push(ChartWarning::new(
180                        "unreachable_state",
181                        format!("/state/states/{index}"),
182                        format!(
183                            "no transition leads to \"{}\" and it is not the initial state",
184                            state.id
185                        ),
186                    ));
187                }
188            }
189        }
190        Ok(warnings)
191    }
192}
193
194impl StateSpec {
195    fn validate_states(&self) -> Result<(), ChartError> {
196        if self.states.is_empty() {
197            return Err(ChartError::new(
198                "empty_data",
199                "/state/states",
200                "provide at least one state",
201            ));
202        }
203        if self.states.len() > MAX_STATES {
204            return Err(ChartError::new(
205                "too_many_states",
206                "/state/states",
207                format!("at most {MAX_STATES} states are supported"),
208            ));
209        }
210        for (index, state) in self.states.iter().enumerate() {
211            let path = format!("/state/states/{index}");
212            if !is_identifier(&state.id) {
213                return Err(ChartError::new(
214                    "invalid_id",
215                    format!("{path}/id"),
216                    "use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
217                ));
218            }
219            if self.states[..index]
220                .iter()
221                .any(|earlier| earlier.id == state.id)
222            {
223                return Err(ChartError::new(
224                    "duplicate_id",
225                    format!("{path}/id"),
226                    format!(
227                        "the id \"{}\" is already taken by an earlier state",
228                        state.id
229                    ),
230                ));
231            }
232            validate_text(&state.label, &format!("{path}/label"), 60)?;
233            if let Some(sublabel) = &state.sublabel {
234                validate_text(sublabel, &format!("{path}/sublabel"), 60)?;
235            }
236            if state.is_final && state.kind == StateKind::Choice {
237                return Err(ChartError::new(
238                    "option_not_supported",
239                    format!("{path}/final"),
240                    "a choice is passed through at once and cannot be final",
241                ));
242            }
243        }
244        self.validate_composites()
245    }
246
247    /// Composite states: they lie in composite states only, without a circle and at most four
248    /// deep, are never final, and hold at least one state of their own.
249    fn validate_composites(&self) -> Result<(), ChartError> {
250        for (index, state) in self.states.iter().enumerate() {
251            let path = format!("/state/states/{index}");
252            if let Some(parent) = &state.parent {
253                let Some(outer) = self.state(parent) else {
254                    return Err(ChartError::new(
255                        "unknown_state",
256                        format!("{path}/in"),
257                        format!("no state has the id \"{parent}\""),
258                    ));
259                };
260                if !self.is_composite(outer) {
261                    return Err(ChartError::new(
262                        "not_composite",
263                        format!("{path}/in"),
264                        format!(
265                            "\"{parent}\" is no composite state; give it \"kind\": \"composite\""
266                        ),
267                    ));
268                }
269            }
270            let Some(chain) = self.composites_around(index) else {
271                return Err(ChartError::new(
272                    "state_cycle",
273                    format!("{path}/in"),
274                    "composite states lie in each other in a circle",
275                ));
276            };
277            if chain.len() >= MAX_COMPOSITE_DEPTH && self.is_composite(index) {
278                return Err(ChartError::new(
279                    "states_too_deep",
280                    format!("{path}/in"),
281                    format!("composite states nest at most {MAX_COMPOSITE_DEPTH} deep"),
282                ));
283            }
284            if self.is_composite(index) {
285                if state.role.is_some() {
286                    return Err(ChartError::new(
287                        "option_not_supported",
288                        format!("{path}/role"),
289                        "a composite state is a frame and takes no role; give the states inside it roles",
290                    ));
291                }
292                if state.is_final {
293                    return Err(ChartError::new(
294                        "option_not_supported",
295                        format!("{path}/final"),
296                        "a composite state is a frame and cannot be final; make a state inside it final",
297                    ));
298                }
299                let holds = (0..self.states.len()).any(|inner| {
300                    !self.is_composite(inner)
301                        && self
302                            .composites_around(inner)
303                            .is_some_and(|chain| chain.contains(&index))
304                });
305                if !holds {
306                    return Err(ChartError::new(
307                        "empty_composite",
308                        path,
309                        format!("no state lies in \"{}\"", state.id),
310                    ));
311                }
312            }
313        }
314        Ok(())
315    }
316
317    /// An error when `id` names a composite state, which transitions cannot reach.
318    fn reject_composite(&self, id: &str, path: String) -> Result<(), ChartError> {
319        match self.state(id) {
320            Some(index) if self.is_composite(index) => Err(ChartError::new(
321                "composite_not_allowed",
322                path,
323                format!("\"{id}\" is a composite state, a frame; name a state inside it"),
324            )),
325            _ => Ok(()),
326        }
327    }
328
329    fn validate_transitions(&self) -> Result<(), ChartError> {
330        if self.transitions.len() > MAX_TRANSITIONS {
331            return Err(ChartError::new(
332                "too_many_transitions",
333                "/state/transitions",
334                format!("at most {MAX_TRANSITIONS} transitions are supported"),
335            ));
336        }
337        for (index, transition) in self.transitions.iter().enumerate() {
338            let path = format!("/state/transitions/{index}");
339            for (field, id) in [("from", &transition.from), ("to", &transition.to)] {
340                if self.state(id).is_none() {
341                    return Err(ChartError::new(
342                        "unknown_state",
343                        format!("{path}/{field}"),
344                        format!("no state has the id \"{id}\""),
345                    ));
346                }
347                self.reject_composite(id, format!("{path}/{field}"))?;
348            }
349            for (field, text) in [
350                ("event", &transition.event),
351                ("guard", &transition.guard),
352                ("action", &transition.action),
353            ] {
354                if let Some(text) = text {
355                    validate_text(text, &format!("{path}/{field}"), 40)?;
356                }
357            }
358            validate_role(transition.role, transition.dash, &path)?;
359        }
360        Ok(())
361    }
362
363    fn validate_initial_and_path(&self) -> Result<(), ChartError> {
364        if let Some(initial) = &self.initial
365            && self.state(initial).is_none()
366        {
367            return Err(ChartError::new(
368                "unknown_state",
369                "/state/initial",
370                format!("no state has the id \"{initial}\""),
371            ));
372        }
373        if let Some(initial) = &self.initial {
374            self.reject_composite(initial, "/state/initial".to_owned())?;
375        }
376        for (index, id) in self.main_path.iter().enumerate() {
377            if self.state(id).is_none() {
378                return Err(ChartError::new(
379                    "unknown_state",
380                    format!("/state/mainPath/{index}"),
381                    format!("no state has the id \"{id}\""),
382                ));
383            }
384            self.reject_composite(id, format!("/state/mainPath/{index}"))?;
385        }
386        for (index, pair) in self.main_path.windows(2).enumerate() {
387            let joined = self
388                .transitions
389                .iter()
390                .any(|transition| transition.from == pair[0] && transition.to == pair[1]);
391            if !joined {
392                return Err(ChartError::new(
393                    "main_path_gap",
394                    format!("/state/mainPath/{}", index + 1),
395                    format!(
396                        "no transition leads from \"{}\" to \"{}\"; the main path follows transitions",
397                        pair[0], pair[1]
398                    ),
399                ));
400            }
401        }
402        Ok(())
403    }
404}