Skip to main content

logicaffeine_language/
lint.rs

1//! The value-semantics copy lint.
2//!
3//! Collections and records are VALUES in LOGOS: `Let opens be b's opens.` binds `opens` to a
4//! COPY of `b`'s field, not a reference to it. `vdom.lg` did this at four sites —
5//!
6//! ```text
7//! Let opens be b's opens.
8//! Pop from opens into at.
9//! ```
10//!
11//! — popping the copy and never touching `b`'s own `opens` again, so the value `b` actually
12//! holds never changes. A new author reaches for exactly this shape, because it reads as if it
13//! mutates `b` in place.
14//!
15//! [`CopyLintScope`] fires when a local is bound from a field access (`b's opens`, `a's b's c`)
16//! or another local read by name (`Let a be b.` — a local is copied the same way a field is), is
17//! then mutated through `Pop`/`Push`/`Add`/`Remove`/`Set item … of …`/`Set …'s field`, and after
18//! its LAST such mutation is never read again and never written back to where it came from. It
19//! is a WARNING, not an error: using the copy as scratch space and discarding it is legitimate,
20//! so a read of any kind (handing it to a function, `Show`ing it, using it as another
21//! statement's condition) after the last mutation silences the lint entirely.
22//!
23//! One scope tracks one function/action/handler body — see
24//! [`Parser::parse_statement`](crate::parser::Parser) for where each statement, at every nesting
25//! depth (a `Repeat`/`If`/`While` body included — all of them recurse back through the same
26//! per-statement dispatch a scope observes through), feeds into whichever scope is currently
27//! open. A local's tracked lifetime is until it is next bound to something else (a second `Let`
28//! of the same name finalizes the first binding's story right there) or the function ends —
29//! deliberately NOT scoped tighter to a nested block, matching how a `Repeat` body's `Let`
30//! re-runs and re-copies every iteration regardless of where its own block happens to end.
31//!
32//! What this deliberately does not chase, so a missed case only ever means a MISSED warning
33//! (never a wrong one): a read inside a nested closure body, an interpolated view template, or a
34//! page-test `Dom` query. A local's value crossing into one of those is read there in a way this
35//! pass has no reason to disbelieve, so it stays conservative rather than silent about the gap.
36
37use crate::ast::stmt::{Expr, Stmt, StringPart};
38use crate::token::Span;
39use logicaffeine_base::{Interner, Symbol};
40use std::collections::HashMap;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Severity {
44    Warning,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum FindingKind {
49    /// A local copied from a field or another local, mutated, and never read or written back
50    /// after its last mutation.
51    OrphanedCopy,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Finding {
56    pub kind: FindingKind,
57    pub severity: Severity,
58    pub span: Span,
59    pub message: String,
60}
61
62/// A place an assignment can write through, as the chain of names that reaches it — `b's opens`
63/// is `[b, opens]`, `a's b's c` is `[a, b, c]`, a bare local `b` is `[b]`. Two expressions name
64/// the SAME place iff their chains are equal, which is all a write-back check needs: it never
65/// has to hold onto the original `Expr` (or its arena lifetime) at all.
66#[derive(Clone, PartialEq, Eq)]
67struct Place(Vec<Symbol>);
68
69fn place_of(expr: &Expr<'_>) -> Option<Place> {
70    fn chain(expr: &Expr<'_>, path: &mut Vec<Symbol>) -> bool {
71        match expr {
72            Expr::Identifier(sym) => {
73                path.push(*sym);
74                true
75            }
76            Expr::FieldAccess { object, field } => {
77                if chain(object, path) {
78                    path.push(*field);
79                    true
80                } else {
81                    false
82                }
83            }
84            _ => false,
85        }
86    }
87    let mut path = Vec::new();
88    chain(expr, &mut path).then_some(Place(path))
89}
90
91/// Where a tracked local's value came from. Either is a value copy in LOGOS; a fresh literal or
92/// constructor is not (there is nothing to write back TO), so `copy_source` never reports one.
93#[derive(Clone, PartialEq, Eq)]
94enum CopySource {
95    /// `Let x be <place>.` where `<place>` is a field-access chain (`b's opens`) — written back
96    /// with `Set <place> to x.` (lowered to `Stmt::SetField`).
97    Field(Place),
98    /// `Let x be y.` where `y` is a bare local/parameter — written back with `Set y to x.`
99    /// (`Stmt::Set`).
100    Local(Symbol),
101}
102
103struct Binding {
104    source: CopySource,
105    /// A human-readable rendering of `source`, computed once at bind time (`describe_expr`) so
106    /// the message never needs the original `Expr` back.
107    source_desc: String,
108    last_mutation: Option<Span>,
109    /// Read, or written back, since `last_mutation` — starts `true` (nothing to settle before
110    /// the first mutation) and is cleared by every fresh mutation.
111    settled_since_mutation: bool,
112}
113
114/// One function/action/handler body's worth of tracking state — see the module doc comment.
115#[derive(Default)]
116pub struct CopyLintScope {
117    bindings: HashMap<Symbol, Binding>,
118    findings: Vec<Finding>,
119}
120
121impl CopyLintScope {
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Feed one just-parsed statement, at whatever nesting depth, into this scope.
127    pub fn observe(&mut self, span: Span, stmt: &Stmt<'_>, interner: &Interner) {
128        match stmt {
129            Stmt::Let { var, value, .. } => {
130                self.rebind(*var, interner);
131                if let Some(source) = copy_source(value) {
132                    let source_desc = describe_source(&source, interner);
133                    self.bindings.insert(
134                        *var,
135                        Binding {
136                            source,
137                            source_desc,
138                            last_mutation: None,
139                            settled_since_mutation: true,
140                        },
141                    );
142                } else {
143                    mark_reads(value, &mut self.bindings);
144                }
145            }
146
147            Stmt::Set { target, value } => {
148                mark_reads(value, &mut self.bindings);
149                for binding in self.bindings.values_mut() {
150                    if binding.source == CopySource::Local(*target) {
151                        binding.settled_since_mutation = true;
152                    }
153                }
154                self.rebind(*target, interner);
155            }
156
157            Stmt::SetField {
158                object,
159                field,
160                value,
161            } => {
162                mark_reads(value, &mut self.bindings);
163                let mut mutated = false;
164                if let Expr::Identifier(sym) = object {
165                    if let Some(binding) = self.bindings.get_mut(sym) {
166                        binding.last_mutation = Some(span);
167                        binding.settled_since_mutation = false;
168                        mutated = true;
169                    }
170                }
171                if !mutated {
172                    if let Some(mut place) = place_of(object) {
173                        place.0.push(*field);
174                        for binding in self.bindings.values_mut() {
175                            if binding.source == CopySource::Field(place.clone()) {
176                                binding.settled_since_mutation = true;
177                            }
178                        }
179                    }
180                }
181            }
182
183            Stmt::Push { value, collection }
184            | Stmt::Add { value, collection }
185            | Stmt::Remove { value, collection } => {
186                mark_reads(value, &mut self.bindings);
187                mutate_place(collection, span, &mut self.bindings);
188            }
189            Stmt::Pop { collection, .. } => {
190                mutate_place(collection, span, &mut self.bindings);
191            }
192            Stmt::SetIndex {
193                collection,
194                index,
195                value,
196            } => {
197                mark_reads(index, &mut self.bindings);
198                mark_reads(value, &mut self.bindings);
199                mutate_place(collection, span, &mut self.bindings);
200            }
201
202            Stmt::Swap { a, b } => {
203                for sym in [a, b] {
204                    if let Some(binding) = self.bindings.get_mut(sym) {
205                        binding.settled_since_mutation = true;
206                    }
207                }
208            }
209
210            Stmt::If { cond, .. } | Stmt::While { cond, .. } => {
211                mark_reads(cond, &mut self.bindings)
212            }
213            Stmt::Repeat { iterable, .. } => mark_reads(iterable, &mut self.bindings),
214            Stmt::Inspect { target, .. } => mark_reads(target, &mut self.bindings),
215
216            Stmt::Return { value: Some(v) } => mark_reads(v, &mut self.bindings),
217            Stmt::Show { object, recipient } | Stmt::Give { object, recipient } => {
218                mark_reads(object, &mut self.bindings);
219                mark_reads(recipient, &mut self.bindings);
220            }
221            Stmt::Call { args, .. } => {
222                for a in args {
223                    mark_reads(a, &mut self.bindings);
224                }
225            }
226            Stmt::RuntimeAssert {
227                condition, reason, ..
228            } => {
229                mark_reads(condition, &mut self.bindings);
230                if let Some(r) = reason {
231                    mark_reads(r, &mut self.bindings);
232                }
233            }
234            Stmt::Expect { condition } => mark_reads(condition, &mut self.bindings),
235            Stmt::Fail { reason } => mark_reads(reason, &mut self.bindings),
236            Stmt::Observe { payloads, .. } => {
237                for (_, v) in payloads {
238                    mark_reads(v, &mut self.bindings);
239                }
240            }
241
242            _ => {}
243        }
244    }
245
246    /// A `Let`/`Set` naming an already-tracked local again ends that binding's story right
247    /// here — whatever comes after belongs to the NEW value, not the copy this local held a
248    /// moment ago.
249    fn rebind(&mut self, var: Symbol, interner: &Interner) {
250        if let Some(old) = self.bindings.remove(&var) {
251            self.finalize(var, &old, interner);
252        }
253    }
254
255    fn finalize(&mut self, var: Symbol, binding: &Binding, interner: &Interner) {
256        let Some(span) = binding.last_mutation else {
257            return;
258        };
259        if binding.settled_since_mutation {
260            return;
261        }
262        self.findings.push(Finding {
263            kind: FindingKind::OrphanedCopy,
264            severity: Severity::Warning,
265            span,
266            message: orphaned_copy_message(var, &binding.source_desc, interner),
267        });
268    }
269
270    /// Every binding still open when the function body ends is finalized here — its own last
271    /// mutation is the most recent thing that could ever have settled it.
272    pub fn finish(mut self, interner: &Interner) -> Vec<Finding> {
273        let remaining: Vec<(Symbol, Binding)> = self.bindings.drain().collect();
274        for (var, binding) in remaining {
275            self.finalize(var, &binding, interner);
276        }
277        self.findings
278    }
279}
280
281fn mutate_place(place: &Expr<'_>, span: Span, bindings: &mut HashMap<Symbol, Binding>) {
282    if let Expr::Identifier(sym) = place {
283        if let Some(binding) = bindings.get_mut(sym) {
284            binding.last_mutation = Some(span);
285            binding.settled_since_mutation = false;
286        }
287    }
288}
289
290fn copy_source(value: &Expr<'_>) -> Option<CopySource> {
291    match value {
292        Expr::FieldAccess { .. } => place_of(value).map(CopySource::Field),
293        Expr::Identifier(sym) => Some(CopySource::Local(*sym)),
294        _ => None,
295    }
296}
297
298fn describe_source(source: &CopySource, interner: &Interner) -> String {
299    match source {
300        CopySource::Field(place) => place
301            .0
302            .iter()
303            .map(|s| interner.resolve(*s))
304            .collect::<Vec<_>>()
305            .join("'s "),
306        CopySource::Local(sym) => interner.resolve(*sym).to_string(),
307    }
308}
309
310fn orphaned_copy_message(var: Symbol, source_desc: &str, interner: &Interner) -> String {
311    let var_str = interner.resolve(var);
312    format!(
313        "'{var_str}' is a copy of '{source_desc}' — collections and records are values in LOGOS, \
314        so this mutates the copy, never '{source_desc}' itself. Either write it back (`Set \
315        {source_desc} to {var_str}.`), or mutate '{source_desc}' directly."
316    )
317}
318
319/// Marks every tracked local read anywhere inside `expr` as settled. Recurses through the
320/// expression shapes that can hold a local's read (an operand, an argument, a collection
321/// element, an interpolation hole); a shape this does not open (a closure body, a view
322/// template, a `Dom` query) is a documented, deliberate gap — see the module doc comment.
323fn mark_reads(expr: &Expr<'_>, bindings: &mut HashMap<Symbol, Binding>) {
324    match expr {
325        Expr::Identifier(sym) => {
326            if let Some(binding) = bindings.get_mut(sym) {
327                binding.settled_since_mutation = true;
328            }
329        }
330        Expr::FieldAccess { object, .. } => mark_reads(object, bindings),
331        Expr::BinaryOp { left, right, .. } => {
332            mark_reads(left, bindings);
333            mark_reads(right, bindings);
334        }
335        Expr::Not { operand } | Expr::Copy { expr: operand } | Expr::Give { value: operand } => {
336            mark_reads(operand, bindings);
337        }
338        Expr::Call { args, .. } => {
339            for a in args {
340                mark_reads(a, bindings);
341            }
342        }
343        Expr::CallExpr { callee, args } => {
344            mark_reads(callee, bindings);
345            for a in args {
346                mark_reads(a, bindings);
347            }
348        }
349        Expr::Index { collection, index } => {
350            mark_reads(collection, bindings);
351            mark_reads(index, bindings);
352        }
353        Expr::Slice {
354            collection,
355            start,
356            end,
357        } => {
358            mark_reads(collection, bindings);
359            mark_reads(start, bindings);
360            mark_reads(end, bindings);
361        }
362        Expr::Length { collection } | Expr::ManifestOf { zone: collection } => {
363            mark_reads(collection, bindings);
364        }
365        Expr::Contains { collection, value } => {
366            mark_reads(collection, bindings);
367            mark_reads(value, bindings);
368        }
369        Expr::Union { left, right } | Expr::Intersection { left, right } => {
370            mark_reads(left, bindings);
371            mark_reads(right, bindings);
372        }
373        Expr::ChunkAt { index, zone } => {
374            mark_reads(index, bindings);
375            mark_reads(zone, bindings);
376        }
377        Expr::List(items) | Expr::Tuple(items) => {
378            for i in items {
379                mark_reads(i, bindings);
380            }
381        }
382        Expr::Range { start, end } => {
383            mark_reads(start, bindings);
384            mark_reads(end, bindings);
385        }
386        Expr::New { init_fields, .. } => {
387            for (_, v) in init_fields {
388                mark_reads(v, bindings);
389            }
390        }
391        Expr::NewVariant { fields, .. } => {
392            for (_, v) in fields {
393                mark_reads(v, bindings);
394            }
395        }
396        Expr::OptionSome { value } => mark_reads(value, bindings),
397        Expr::WithCapacity { value, capacity } => {
398            mark_reads(value, bindings);
399            mark_reads(capacity, bindings);
400        }
401        Expr::InterpolatedString(parts) => {
402            for part in parts.iter() {
403                if let StringPart::Expr { value, .. } = part {
404                    mark_reads(value, bindings);
405                }
406            }
407        }
408        _ => {}
409    }
410}