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 ¶ms[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}