Skip to main content

nucleus_compiler/
solver.rs

1//! The hardware constraint solver.
2//!
3//! Takes a parsed [`Config`] and a [`Database`] and produces a list of
4//! [`Conflict`]s. Phase 2 detects exactly the four conflict classes from the
5//! README roadmap:
6//!
7//! 1. **Pin collision** — two peripheral signals on one physical pin.
8//! 2. **AF mismatch** — a pin assigned to a peripheral it doesn't connect to.
9//! 3. **Missing required pin** — a peripheral declared without a required pin.
10//! 4. **Clock domain disabled** — a peripheral whose bus clock is turned off.
11//!
12//! Per the scope rules there is no DMA-collision or full clock-tree analysis.
13
14use std::collections::BTreeMap;
15use std::fmt;
16use std::str::FromStr;
17
18use nucleus_db::{Database, Pin};
19
20use crate::config::Config;
21use crate::model::{self, Bus};
22
23/// A single resolved conflict. Every variant is an error (it makes the config
24/// un-buildable); `nucleus check` exits non-zero if any are present.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Conflict {
27    /// Two signals assigned to the same physical pin.
28    PinCollision {
29        pin: Pin,
30        /// The colliding `(peripheral, signal)` users of the pin, sorted.
31        users: Vec<SignalRef>,
32    },
33    /// A pin that does not expose the requested peripheral signal on this MCU.
34    AfMismatch {
35        pin: Pin,
36        peripheral: String,
37        signal: String,
38    },
39    /// A pin role whose string value is not a valid pin name.
40    InvalidPin {
41        peripheral: String,
42        key: String,
43        value: String,
44    },
45    /// A required pin role left unset.
46    MissingPin {
47        peripheral: String,
48        key: String,
49        signal: String,
50    },
51    /// A peripheral configured while its bus clock domain is disabled.
52    ClockDomainDisabled { peripheral: String, bus: Bus },
53    /// A peripheral instance that does not exist on the selected MCU family.
54    PeripheralUnavailable { peripheral: String, family: String },
55}
56
57/// A `(peripheral, signal)` pair identifying one use of a pin.
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
59pub struct SignalRef {
60    pub peripheral: String,
61    pub signal: String,
62}
63
64impl fmt::Display for SignalRef {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "{}_{}", self.peripheral, self.signal)
67    }
68}
69
70impl fmt::Display for Conflict {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Conflict::PinCollision { pin, users } => {
74                let names: Vec<String> = users.iter().map(ToString::to_string).collect();
75                write!(
76                    f,
77                    "pin collision on {pin}: assigned to {}",
78                    names.join(" and ")
79                )
80            }
81            Conflict::AfMismatch {
82                pin,
83                peripheral,
84                signal,
85            } => write!(
86                f,
87                "AF mismatch: {pin} has no alternate function for {peripheral}_{signal} on this MCU"
88            ),
89            Conflict::InvalidPin {
90                peripheral,
91                key,
92                value,
93            } => write!(
94                f,
95                "invalid pin: {peripheral}.{key} = {value:?} is not a valid pin name"
96            ),
97            Conflict::MissingPin {
98                peripheral,
99                key,
100                signal,
101            } => write!(
102                f,
103                "missing required pin: {peripheral} needs a {key} pin ({peripheral}_{signal})"
104            ),
105            Conflict::ClockDomainDisabled { peripheral, bus } => write!(
106                f,
107                "clock domain disabled: {peripheral} is on {} but [clocks].{} = false",
108                bus.name(),
109                bus.name().to_ascii_lowercase()
110            ),
111            Conflict::PeripheralUnavailable { peripheral, family } => {
112                write!(f, "peripheral {peripheral} is not available on {family}")
113            }
114        }
115    }
116}
117
118/// Run the solver over `config` against `db`, returning all conflicts in a
119/// deterministic order (so output and tests are stable across runs).
120pub fn solve(config: &Config, db: &Database) -> Vec<Conflict> {
121    let mut conflicts = Vec::new();
122    // pin -> the signals assigned to it, for collision detection.
123    let mut pin_users: BTreeMap<Pin, Vec<SignalRef>> = BTreeMap::new();
124
125    // BTreeMap iteration is lexical, giving deterministic ordering.
126    for (instance, table) in &config.peripherals {
127        let Some(roles) = model::roles_for(instance) else {
128            // Unmodelled peripheral kind: nothing to check.
129            continue;
130        };
131        let peripheral = model::peripheral_name(instance);
132
133        // A peripheral the selected family doesn't have at all: report once and
134        // skip pin/clock checks (which would otherwise emit confusing AF/missing
135        // errors for a nonexistent block).
136        if !db.has_peripheral(&peripheral) {
137            conflicts.push(Conflict::PeripheralUnavailable {
138                peripheral: peripheral.clone(),
139                family: config.device.family.clone(),
140            });
141            continue;
142        }
143
144        // Clock-domain check: one diagnostic per peripheral, before pin work.
145        if let Some(bus) = model::peripheral_bus(&peripheral) {
146            let enabled = match bus {
147                Bus::Ahb1 => config.clocks.ahb1,
148                Bus::Apb1 => config.clocks.apb1,
149                Bus::Apb2 => config.clocks.apb2,
150            };
151            if !enabled {
152                conflicts.push(Conflict::ClockDomainDisabled {
153                    peripheral: peripheral.clone(),
154                    bus,
155                });
156            }
157        }
158
159        for role in roles {
160            match table.pin_str(role.key) {
161                None => {
162                    if role.required {
163                        conflicts.push(Conflict::MissingPin {
164                            peripheral: peripheral.clone(),
165                            key: role.key.to_string(),
166                            signal: role.signal.to_string(),
167                        });
168                    }
169                }
170                Some(value) => {
171                    let Ok(pin) = Pin::from_str(value) else {
172                        conflicts.push(Conflict::InvalidPin {
173                            peripheral: peripheral.clone(),
174                            key: role.key.to_string(),
175                            value: value.to_string(),
176                        });
177                        continue;
178                    };
179                    // AF mismatch: does this pin actually expose this signal?
180                    if db.find_af(pin, &peripheral, role.signal).is_none() {
181                        conflicts.push(Conflict::AfMismatch {
182                            pin,
183                            peripheral: peripheral.clone(),
184                            signal: role.signal.to_string(),
185                        });
186                    }
187                    // Record for collision detection regardless of AF validity:
188                    // two peripherals fighting over a pin is worth reporting even
189                    // if one of them is also mis-wired.
190                    pin_users.entry(pin).or_default().push(SignalRef {
191                        peripheral: peripheral.clone(),
192                        signal: role.signal.to_string(),
193                    });
194                }
195            }
196        }
197    }
198
199    // One PinCollision per over-subscribed pin (not per pair), so a doubly-used
200    // pin yields exactly one error.
201    for (pin, mut users) in pin_users {
202        if users.len() > 1 {
203            users.sort();
204            conflicts.push(Conflict::PinCollision { pin, users });
205        }
206    }
207
208    conflicts
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::config;
215
216    fn db() -> Database {
217        Database::f446re()
218    }
219
220    fn solve_toml(text: &str) -> Vec<Conflict> {
221        let cfg = config::parse(text).unwrap();
222        solve(&cfg, &db())
223    }
224
225    #[test]
226    fn clean_config_has_no_conflicts() {
227        let conflicts = solve_toml(
228            r#"
229[peripherals.usart2]
230tx = "PA2"
231rx = "PA3"
232
233[peripherals.spi1]
234mosi = "PA7"
235miso = "PA6"
236sck = "PA5"
237nss = "PA4"
238
239[peripherals.i2c1]
240sda = "PB9"
241scl = "PB8"
242"#,
243        );
244        assert_eq!(
245            conflicts,
246            vec![],
247            "expected clean config, got {conflicts:?}"
248        );
249    }
250
251    #[test]
252    fn detects_pin_collision() {
253        // PA5 is SPI1_SCK and also (wrongly) USART2... we put two real signals
254        // on PA5 to force a collision.
255        let conflicts = solve_toml(
256            r#"
257[peripherals.spi1]
258mosi = "PA7"
259miso = "PA6"
260sck = "PA5"
261
262[peripherals.tim2]
263channel1 = "PA5"
264"#,
265        );
266        let collisions: Vec<_> = conflicts
267            .iter()
268            .filter(|c| matches!(c, Conflict::PinCollision { .. }))
269            .collect();
270        assert_eq!(collisions.len(), 1, "got {conflicts:?}");
271        if let Conflict::PinCollision { pin, users } = collisions[0] {
272            assert_eq!(pin.to_string(), "PA5");
273            assert_eq!(users.len(), 2);
274        }
275    }
276
277    #[test]
278    fn detects_af_mismatch() {
279        // PB0 does not carry USART2_TX on the F446.
280        let conflicts = solve_toml(
281            r#"
282[peripherals.usart2]
283tx = "PB0"
284rx = "PA3"
285"#,
286        );
287        assert!(
288            conflicts.iter().any(|c| matches!(
289                c,
290                Conflict::AfMismatch { pin, signal, .. }
291                    if pin.to_string() == "PB0" && signal == "TX"
292            )),
293            "got {conflicts:?}"
294        );
295    }
296
297    #[test]
298    fn detects_missing_required_pin() {
299        // SPI1 without MOSI.
300        let conflicts = solve_toml(
301            r#"
302[peripherals.spi1]
303miso = "PA6"
304sck = "PA5"
305"#,
306        );
307        assert!(
308            conflicts.iter().any(|c| matches!(
309                c,
310                Conflict::MissingPin { peripheral, signal, .. }
311                    if peripheral == "SPI1" && signal == "MOSI"
312            )),
313            "got {conflicts:?}"
314        );
315    }
316
317    #[test]
318    fn missing_optional_pin_is_not_a_conflict() {
319        // SPI1 without NSS (optional) is fine.
320        let conflicts = solve_toml(
321            r#"
322[peripherals.spi1]
323mosi = "PA7"
324miso = "PA6"
325sck = "PA5"
326"#,
327        );
328        assert_eq!(conflicts, vec![]);
329    }
330
331    #[test]
332    fn detects_clock_domain_disabled() {
333        // SPI1 lives on APB2; disabling APB2 must flag it.
334        let conflicts = solve_toml(
335            r#"
336[clocks]
337apb2 = false
338
339[peripherals.spi1]
340mosi = "PA7"
341miso = "PA6"
342sck = "PA5"
343"#,
344        );
345        assert!(
346            conflicts.iter().any(|c| matches!(
347                c,
348                Conflict::ClockDomainDisabled { peripheral, bus }
349                    if peripheral == "SPI1" && *bus == Bus::Apb2
350            )),
351            "got {conflicts:?}"
352        );
353    }
354
355    #[test]
356    fn detects_peripheral_unavailable_on_family() {
357        // UART4 exists on the F446 but not on the F411RE package. Configured
358        // under family = STM32F411RE it must produce exactly one
359        // PeripheralUnavailable conflict and no spurious pin conflicts.
360        let cfg = config::parse(
361            "[device]\nfamily = \"STM32F411RE\"\n\n[peripherals.uart4]\ntx = \"PA0\"\nrx = \"PA1\"\n",
362        )
363        .unwrap();
364        let conflicts = solve(&cfg, &Database::f411re());
365        assert_eq!(
366            conflicts,
367            vec![Conflict::PeripheralUnavailable {
368                peripheral: "UART4".to_string(),
369                family: "STM32F411RE".to_string(),
370            }],
371            "got {conflicts:?}"
372        );
373    }
374
375    #[test]
376    fn invalid_pin_name_reported() {
377        let conflicts = solve_toml(
378            r#"
379[peripherals.usart2]
380tx = "PZ9"
381rx = "PA3"
382"#,
383        );
384        assert!(
385            conflicts
386                .iter()
387                .any(|c| matches!(c, Conflict::InvalidPin { value, .. } if value == "PZ9")),
388            "got {conflicts:?}"
389        );
390    }
391}