Skip to main content

logicaffeine_compile/analysis/
dimension_check.rs

1//! Dimension-aware static analysis — rejects dimension-incoherent quantity arithmetic at COMPILE
2//! time, before any code is generated. `2 meters + 1 gram` is a *type error*, not a runtime panic:
3//! a length and a mass have no common dimension, so adding them cannot mean anything.
4//!
5//! This is a dedicated pass in the [`crate::analysis::escape::EscapeChecker`] /
6//! [`crate::analysis::ownership`] mold, wired as a gate in `compile.rs`. It runs on the SAME AST the
7//! interpreter and the AOT compiler share, so a dimension error is reported identically on every tier
8//! before execution.
9//!
10//! **Soundness is conservative.** Every quantity *value* already carries its dimension at runtime;
11//! this pass only adds a *static* rejection where it can PROVE incompatibility — i.e. when both
12//! operands' dimensions are statically known and differ. A quantity of unknown dimension (a
13//! `Quantity` function parameter, a collection element, a value from an opaque call) is treated as
14//! dimension-polymorphic and deferred to the existing runtime check. So this pass never rejects a
15//! correct program; it only promotes provable runtime failures to compile-time errors.
16
17use std::collections::HashMap;
18
19use logicaffeine_base::quantity::units;
20use logicaffeine_base::Dimension;
21
22use crate::ast::stmt::{BinaryOpKind, Expr, Literal, Stmt, TypeExpr};
23use crate::intern::{Interner, Symbol};
24use crate::token::Span;
25
26/// The dimensional nature of an expression's value.
27#[derive(Clone, Copy, PartialEq)]
28enum QDim {
29    /// A quantity whose dimension is statically known (e.g. a literal `2 meters`).
30    Known(Dimension),
31    /// A quantity whose dimension is not statically known (a `Quantity` parameter, a collection
32    /// element, …) — dimension-polymorphic, deferred to the runtime check.
33    Unknown,
34    /// Not a quantity (a plain number, text, struct, …).
35    NotQuantity,
36}
37
38impl QDim {
39    fn is_quantity(self) -> bool {
40        !matches!(self, QDim::NotQuantity)
41    }
42}
43
44/// The currency nature of an expression — money's analogue of [`QDim`]. The same pass that proves
45/// dimension coherence also proves currency coherence (`5 USD + 1 EUR` has no answer without a rate
46/// context, exactly like `meter + gram`).
47#[derive(Clone, PartialEq, Eq)]
48enum CurInfo {
49    /// Money whose currency is statically known (a `19.99 USD` / `money(_,"USD")` literal).
50    Known(String),
51    /// Money whose currency is not statically known (a `Money` parameter, a collection element).
52    Unknown,
53    /// Not money.
54    NotMoney,
55}
56
57impl CurInfo {
58    fn is_money(&self) -> bool {
59        !matches!(self, CurInfo::NotMoney)
60    }
61}
62
63/// A dimension coherence error, with the same shape the other analysis passes use so `compile.rs`
64/// can convert it to a `ParseError` uniformly.
65pub struct DimensionError {
66    pub message: String,
67    pub span: Span,
68}
69
70impl std::fmt::Display for DimensionError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(f, "{}", self.message)
73    }
74}
75
76pub struct DimensionChecker<'a> {
77    /// Variables (and parameters) whose dimensional nature is known in the current scope.
78    vars: HashMap<Symbol, QDim>,
79    /// User function name → the dimensional nature of its declared return type, so a call to a
80    /// function returning `Quantity of Area` is statically known to be an area.
81    fn_returns: HashMap<Symbol, QDim>,
82    /// Variables whose currency is statically known in the current scope (money's analogue of `vars`).
83    cur_vars: HashMap<Symbol, CurInfo>,
84    interner: &'a Interner,
85}
86
87impl<'a> DimensionChecker<'a> {
88    pub fn new(interner: &'a Interner) -> Self {
89        Self {
90            vars: HashMap::new(),
91            fn_returns: HashMap::new(),
92            cur_vars: HashMap::new(),
93            interner,
94        }
95    }
96
97    pub fn check_program(&mut self, stmts: &[Stmt<'_>]) -> Result<(), DimensionError> {
98        // Pre-pass: record every function's declared return dimension (forward references included).
99        self.collect_fn_returns(stmts);
100        self.check_block(stmts)
101    }
102
103    fn collect_fn_returns(&mut self, stmts: &[Stmt<'_>]) {
104        for stmt in stmts {
105            if let Stmt::FunctionDef { name, return_type, .. } = stmt {
106                let qd = return_type.map(|t| self.dim_from_type(t)).unwrap_or(QDim::NotQuantity);
107                self.fn_returns.insert(*name, qd);
108            }
109        }
110    }
111
112    /// The dimensional nature declared by a type annotation: `Quantity of Length` → `Known(L)`,
113    /// bare `Quantity` → `Unknown` (dimension-polymorphic), anything else → `NotQuantity`.
114    fn dim_from_type(&self, ty: &TypeExpr<'_>) -> QDim {
115        match ty {
116            TypeExpr::Generic { base, params }
117                if self.interner.resolve(*base) == "Quantity" && params.len() == 1 =>
118            {
119                match &params[0] {
120                    TypeExpr::Primitive(s) | TypeExpr::Named(s) => {
121                        match Dimension::by_name(self.interner.resolve(*s)) {
122                            Some(d) => QDim::Known(d),
123                            None => QDim::Unknown,
124                        }
125                    }
126                    _ => QDim::Unknown,
127                }
128            }
129            TypeExpr::Primitive(s) | TypeExpr::Named(s)
130                if self.interner.resolve(*s) == "Quantity" =>
131            {
132                QDim::Unknown
133            }
134            _ => QDim::NotQuantity,
135        }
136    }
137
138    fn check_block(&mut self, stmts: &[Stmt<'_>]) -> Result<(), DimensionError> {
139        for stmt in stmts {
140            self.check_stmt(stmt)?;
141        }
142        Ok(())
143    }
144
145    fn check_stmt(&mut self, stmt: &Stmt<'_>) -> Result<(), DimensionError> {
146        match stmt {
147            Stmt::Let { var, value, ty, .. } => {
148                let inferred = self.infer(value)?;
149                // A `Let d: Quantity of Length be …` declares the dimension authoritatively.
150                let declared = ty.map(|t| self.dim_from_type(t));
151                let d = match declared {
152                    Some(QDim::Known(k)) => QDim::Known(k),
153                    Some(QDim::Unknown) if !inferred.is_quantity() => QDim::Unknown,
154                    _ => inferred,
155                };
156                self.vars.insert(*var, d);
157                // Track the binding's currency too, so `Let p be 5 USD. ... p + 1 EUR.` is caught.
158                let c = self.currency_of(value);
159                self.cur_vars.insert(*var, c);
160            }
161            Stmt::Set { value, .. } => {
162                self.infer(value)?;
163            }
164            Stmt::SetField { object, value, .. } => {
165                self.infer(object)?;
166                self.infer(value)?;
167            }
168            Stmt::If { cond, then_block, else_block } => {
169                self.infer(cond)?;
170                self.check_block(then_block)?;
171                if let Some(e) = else_block {
172                    self.check_block(e)?;
173                }
174            }
175            Stmt::While { cond, body, .. } => {
176                self.infer(cond)?;
177                self.check_block(body)?;
178            }
179            Stmt::Repeat { iterable, body, .. } => {
180                self.infer(iterable)?;
181                self.check_block(body)?;
182            }
183            Stmt::Return { value: Some(e) } => {
184                self.infer(e)?;
185            }
186            Stmt::Show { object, .. } => {
187                self.infer(object)?;
188            }
189            Stmt::RuntimeAssert { condition, .. } => {
190                self.infer(condition)?;
191            }
192            Stmt::Call { args, .. } => {
193                for a in args {
194                    self.infer(a)?;
195                }
196            }
197            Stmt::FunctionDef { params, body, .. } => {
198                // A function body is a fresh scope: a `Quantity` parameter is dimension-polymorphic
199                // (unknown), every other parameter is a non-quantity for our purposes.
200                let saved = self.vars.clone();
201                let saved_cur = self.cur_vars.clone();
202                for (name, ty) in params {
203                    let qd = self.dim_from_type(ty);
204                    if qd.is_quantity() {
205                        // `Quantity of Length` → Known(L); bare `Quantity` → Unknown (polymorphic).
206                        self.vars.insert(*name, qd);
207                    }
208                }
209                self.check_block(body)?;
210                self.vars = saved;
211                self.cur_vars = saved_cur;
212            }
213            _ => {}
214        }
215        Ok(())
216    }
217
218    /// The dimension named by a unit-string argument (`Literal::Text`), if it resolves; an
219    /// unresolved unit is left `Unknown` (the construction itself errors at runtime).
220    fn unit_dim(&self, expr: &Expr<'_>) -> QDim {
221        if let Expr::Literal(Literal::Text(sym)) = expr {
222            if let Some(unit) = units::by_name(self.interner.resolve(*sym)) {
223                return QDim::Known(unit.dimension);
224            }
225        }
226        QDim::Unknown
227    }
228
229    /// The currency of an expression, when statically known. Mirrors [`Self::infer`] but for money:
230    /// a `money(_, "USD")` literal is `Known("USD")`, a Let-bound money variable carries its currency,
231    /// `+ −` and scaling keep it, and a `Money ÷ Money` becomes `NotMoney` (a Rational ratio).
232    fn currency_of(&self, expr: &Expr<'_>) -> CurInfo {
233        match expr {
234            Expr::Identifier(s) => self.cur_vars.get(s).cloned().unwrap_or(CurInfo::NotMoney),
235            Expr::Call { function, args } => {
236                if self.interner.resolve(*function) == "money" && args.len() == 2 {
237                    if let Expr::Literal(Literal::Text(code)) = args[1] {
238                        return CurInfo::Known(self.interner.resolve(*code).to_ascii_uppercase());
239                    }
240                    return CurInfo::Unknown;
241                }
242                CurInfo::NotMoney
243            }
244            Expr::BinaryOp { op, left, right } => match op {
245                BinaryOpKind::Add | BinaryOpKind::Subtract => {
246                    let (l, r) = (self.currency_of(left), self.currency_of(right));
247                    if let CurInfo::Known(_) = l {
248                        l
249                    } else {
250                        r
251                    }
252                }
253                // Scaling money by a number keeps the currency; `Money ÷ Money` is a ratio (not money).
254                BinaryOpKind::Multiply => {
255                    let (l, r) = (self.currency_of(left), self.currency_of(right));
256                    if matches!(l, CurInfo::Known(_) | CurInfo::Unknown) {
257                        l
258                    } else {
259                        r
260                    }
261                }
262                BinaryOpKind::Divide => {
263                    let (l, r) = (self.currency_of(left), self.currency_of(right));
264                    // money ÷ money → ratio (not money); money ÷ number → money.
265                    if r.is_money() {
266                        CurInfo::NotMoney
267                    } else {
268                        l
269                    }
270                }
271                _ => CurInfo::NotMoney,
272            },
273            Expr::Copy { expr } | Expr::Give { value: expr } => self.currency_of(expr),
274            _ => CurInfo::NotMoney,
275        }
276    }
277
278    /// Error if both operands are money of *provably different* currencies (`5 USD + 1 EUR`).
279    fn check_currency_match(
280        &self,
281        left: &Expr<'_>,
282        right: &Expr<'_>,
283        verb: &str,
284    ) -> Result<(), DimensionError> {
285        if let (CurInfo::Known(a), CurInfo::Known(b)) = (self.currency_of(left), self.currency_of(right))
286        {
287            if a != b {
288                return Err(DimensionError {
289                    message: format!("cannot {verb} money of different currencies ({a} vs {b})"),
290                    span: Span::default(),
291                });
292            }
293        }
294        Ok(())
295    }
296
297    /// Infer the dimensional nature of an expression, erroring on a statically-incoherent operation.
298    fn infer(&self, expr: &Expr<'_>) -> Result<QDim, DimensionError> {
299        match expr {
300            Expr::Literal(_) => Ok(QDim::NotQuantity),
301            Expr::Identifier(s) => Ok(self.vars.get(s).copied().unwrap_or(QDim::NotQuantity)),
302
303            Expr::Call { function, args } => {
304                for a in args {
305                    self.infer(a)?;
306                }
307                match self.interner.resolve(*function) {
308                    "quantity" | "convert" if args.len() == 2 => Ok(self.unit_dim(args[1])),
309                    // A user function with a declared `Quantity of <Dim>` return is statically known;
310                    // any other call may return a quantity we can't see into — polymorphic (Unknown).
311                    _ => Ok(self.fn_returns.get(function).copied().unwrap_or(QDim::Unknown)),
312                }
313            }
314
315            Expr::BinaryOp { op, left, right } => {
316                let l = self.infer(left)?;
317                let r = self.infer(right)?;
318                match op {
319                    // `+` / `−` require equal dimensions; provably-different dimensions are rejected.
320                    BinaryOpKind::Add | BinaryOpKind::Subtract => {
321                        self.check_currency_match(
322                            left,
323                            right,
324                            if matches!(op, BinaryOpKind::Add) { "add" } else { "subtract" },
325                        )?;
326                        if let (QDim::Known(a), QDim::Known(b)) = (l, r) {
327                            if a != b {
328                                return Err(self.mismatch_err(
329                                    if matches!(op, BinaryOpKind::Add) { "add" } else { "subtract" },
330                                    a,
331                                    b,
332                                ));
333                            }
334                            return Ok(QDim::Known(a));
335                        }
336                        Ok(self.propagate(l, r))
337                    }
338                    // Ordering two quantities/monies of provably-different kind is meaningless.
339                    BinaryOpKind::Lt | BinaryOpKind::Gt | BinaryOpKind::LtEq | BinaryOpKind::GtEq => {
340                        self.check_currency_match(left, right, "compare")?;
341                        if let (QDim::Known(a), QDim::Known(b)) = (l, r) {
342                            if a != b {
343                                return Err(self.mismatch_err("compare", a, b));
344                            }
345                        }
346                        Ok(QDim::NotQuantity)
347                    }
348                    // `×` combines dimensions (Length × Length = Area); scaling by a number keeps it.
349                    BinaryOpKind::Multiply => Ok(match (l, r) {
350                        (QDim::Known(a), QDim::Known(b)) => QDim::Known(a.mul(b)),
351                        (QDim::Known(a), QDim::NotQuantity) | (QDim::NotQuantity, QDim::Known(a)) => {
352                            QDim::Known(a)
353                        }
354                        _ if l.is_quantity() || r.is_quantity() => QDim::Unknown,
355                        _ => QDim::NotQuantity,
356                    }),
357                    // `÷` subtracts dimensions (Volume ÷ Area = Length); dividing by a number keeps it.
358                    BinaryOpKind::Divide => Ok(match (l, r) {
359                        (QDim::Known(a), QDim::Known(b)) => QDim::Known(a.div(b)),
360                        (QDim::Known(a), QDim::NotQuantity) => QDim::Known(a),
361                        _ if l.is_quantity() || r.is_quantity() => QDim::Unknown,
362                        _ => QDim::NotQuantity,
363                    }),
364                    _ => Ok(QDim::NotQuantity),
365                }
366            }
367
368            // Recurse into expression shapes that nest sub-expressions, so a mismatch buried inside
369            // one is still caught. A collection element may be a quantity of unknown dimension.
370            Expr::Not { operand } => {
371                self.infer(operand)?;
372                Ok(QDim::NotQuantity)
373            }
374            Expr::Index { collection, index } => {
375                self.infer(collection)?;
376                self.infer(index)?;
377                Ok(QDim::Unknown)
378            }
379            Expr::Slice { collection, start, end } => {
380                self.infer(collection)?;
381                self.infer(start)?;
382                self.infer(end)?;
383                Ok(QDim::Unknown)
384            }
385            Expr::Copy { expr } | Expr::Give { value: expr } => self.infer(expr),
386            Expr::Length { collection } => {
387                self.infer(collection)?;
388                Ok(QDim::NotQuantity)
389            }
390            Expr::Contains { collection, value } => {
391                self.infer(collection)?;
392                self.infer(value)?;
393                Ok(QDim::NotQuantity)
394            }
395            _ => Ok(QDim::NotQuantity),
396        }
397    }
398
399    /// For `+`/`−` where at least one side is dimension-unknown: carry a known dimension forward if
400    /// there is one, otherwise stay quantity-but-unknown when either side is a quantity.
401    fn propagate(&self, l: QDim, r: QDim) -> QDim {
402        match (l, r) {
403            (QDim::Known(d), _) | (_, QDim::Known(d)) => QDim::Known(d),
404            _ if l.is_quantity() || r.is_quantity() => QDim::Unknown,
405            _ => QDim::NotQuantity,
406        }
407    }
408
409    fn mismatch_err(&self, verb: &str, a: Dimension, b: Dimension) -> DimensionError {
410        DimensionError {
411            message: format!("cannot {verb} quantities of different dimensions ({a} vs {b})"),
412            span: Span::default(),
413        }
414    }
415}