Skip to main content

seqc/resource_lint/
word.rs

1//! Single-word resource analyzer — simulates one word body at a time
2//! without cross-word knowledge. Used as a fallback or for isolated
3//! analysis passes.
4
5use std::path::Path;
6
7use crate::ast::{MatchArm, Span, Statement, WordDef};
8use crate::lint::{LintDiagnostic, Severity};
9
10use super::state::{InconsistentResource, ResourceKind, StackState, StackValue, TrackedResource};
11
12/// The resource leak analyzer (single-word analysis)
13pub struct ResourceAnalyzer {
14    /// Diagnostics collected during analysis
15    diagnostics: Vec<LintDiagnostic>,
16    /// File being analyzed
17    file: std::path::PathBuf,
18}
19
20impl ResourceAnalyzer {
21    pub fn new(file: &Path) -> Self {
22        ResourceAnalyzer {
23            diagnostics: Vec::new(),
24            file: file.to_path_buf(),
25        }
26    }
27
28    /// Analyze a word definition for resource leaks
29    pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
30        self.diagnostics.clear();
31
32        let mut state = StackState::new();
33
34        // Analyze the word body
35        self.analyze_statements(&word.body, &mut state, word);
36
37        // Check for leaked resources at end of word
38        // Note: Resources still on stack at word end could be:
39        // 1. Intentionally returned (escape) - caller's responsibility
40        // 2. Leaked - forgot to clean up
41        //
42        // For Phase 2a, we apply escape analysis: if a resource is still
43        // on the stack at word end, it's being returned to the caller.
44        // This is valid - the caller becomes responsible for cleanup.
45        // We only warn about resources that are explicitly dropped without
46        // cleanup, or handled inconsistently across branches.
47        //
48        // Phase 2b could add cross-word analysis to track if callers
49        // properly handle returned resources.
50        let _ = state.remaining_resources(); // Intentional: escape = no warning
51
52        std::mem::take(&mut self.diagnostics)
53    }
54
55    /// Analyze a sequence of statements
56    fn analyze_statements(
57        &mut self,
58        statements: &[Statement],
59        state: &mut StackState,
60        word: &WordDef,
61    ) {
62        for stmt in statements {
63            self.analyze_statement(stmt, state, word);
64        }
65    }
66
67    /// Analyze a single statement
68    fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
69        match stmt {
70            Statement::IntLiteral(_)
71            | Statement::FloatLiteral(_)
72            | Statement::BoolLiteral(_)
73            | Statement::StringLiteral(_)
74            | Statement::Symbol(_) => {
75                state.push_unknown();
76            }
77
78            Statement::WordCall { name, span } => {
79                self.analyze_word_call(name, span.as_ref(), state, word);
80            }
81
82            Statement::Quotation { body, .. } => {
83                // Quotations capture the current stack conceptually but don't
84                // execute immediately. For now, just push an unknown value
85                // (the quotation itself). We could analyze the body when
86                // we see `call`, but that's Phase 2b.
87                let _ = body; // Acknowledge we're not analyzing the body yet
88                state.push_unknown();
89            }
90
91            Statement::If {
92                then_branch,
93                else_branch,
94                span: _,
95            } => {
96                self.analyze_if(then_branch, else_branch.as_ref(), state, word);
97            }
98
99            Statement::Match { arms, span: _ } => {
100                self.analyze_match(arms, state, word);
101            }
102        }
103    }
104
105    /// Analyze a word call
106    fn analyze_word_call(
107        &mut self,
108        name: &str,
109        span: Option<&Span>,
110        state: &mut StackState,
111        word: &WordDef,
112    ) {
113        let line = span.map(|s| s.line).unwrap_or(0);
114
115        match name {
116            // Resource-creating words
117            "strand.weave" => {
118                // Pops quotation, pushes WeaveHandle
119                state.pop(); // quotation
120                state.push_resource(ResourceKind::WeaveHandle, line, name);
121            }
122
123            "chan.make" => {
124                // Pushes a new channel
125                state.push_resource(ResourceKind::Channel, line, name);
126            }
127
128            // Resource-consuming words
129            "strand.weave-cancel" => {
130                // Pops and consumes WeaveHandle
131                if let Some(StackValue::Resource(r)) = state.pop()
132                    && r.kind == ResourceKind::WeaveHandle
133                {
134                    state.consume_resource(r);
135                }
136            }
137
138            "chan.close" => {
139                // Pops and consumes Channel
140                if let Some(StackValue::Resource(r)) = state.pop()
141                    && r.kind == ResourceKind::Channel
142                {
143                    state.consume_resource(r);
144                }
145            }
146
147            // strand.resume is special - it returns (handle value bool)
148            // If bool is false, the weave completed and handle is consumed
149            // We can't know statically, so we just track that the handle
150            // is still in play (on the stack after resume)
151            "strand.resume" => {
152                // Pops (handle value), pushes (handle value bool)
153                let value = state.pop(); // value to send
154                let handle = state.pop(); // handle
155
156                // Push them back plus the bool result
157                if let Some(h) = handle {
158                    state.stack.push(h);
159                } else {
160                    state.push_unknown();
161                }
162                if let Some(v) = value {
163                    state.stack.push(v);
164                } else {
165                    state.push_unknown();
166                }
167                state.push_unknown(); // bool result
168            }
169
170            // Stack operations
171            "drop" => {
172                let dropped = state.pop();
173                // If we dropped a resource without consuming it properly, that's a leak
174                // But check if it was already consumed (e.g., transferred via strand.spawn)
175                if let Some(StackValue::Resource(r)) = dropped {
176                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
177                    if !already_consumed {
178                        self.emit_drop_warning(&r, span, word);
179                    }
180                }
181            }
182
183            "dup" => {
184                if let Some(top) = state.peek().cloned() {
185                    state.stack.push(top);
186                } else {
187                    state.push_unknown();
188                }
189            }
190
191            "swap" => {
192                let a = state.pop();
193                let b = state.pop();
194                if let Some(av) = a {
195                    state.stack.push(av);
196                }
197                if let Some(bv) = b {
198                    state.stack.push(bv);
199                }
200            }
201
202            "over" => {
203                // ( a b -- a b a ) - copy second element to top
204                if state.depth() >= 2 {
205                    let second = state.stack[state.depth() - 2].clone();
206                    state.stack.push(second);
207                } else {
208                    state.push_unknown();
209                }
210            }
211
212            "rot" => {
213                // ( a b c -- b c a )
214                let c = state.pop();
215                let b = state.pop();
216                let a = state.pop();
217                if let Some(bv) = b {
218                    state.stack.push(bv);
219                }
220                if let Some(cv) = c {
221                    state.stack.push(cv);
222                }
223                if let Some(av) = a {
224                    state.stack.push(av);
225                }
226            }
227
228            "nip" => {
229                // ( a b -- b ) - drop second
230                let b = state.pop();
231                let a = state.pop();
232                if let Some(StackValue::Resource(r)) = a {
233                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
234                    if !already_consumed {
235                        self.emit_drop_warning(&r, span, word);
236                    }
237                }
238                if let Some(bv) = b {
239                    state.stack.push(bv);
240                }
241            }
242
243            ">aux" => {
244                // Move top of main stack to aux stack (Issue #350)
245                if let Some(val) = state.pop() {
246                    state.aux_stack.push(val);
247                }
248            }
249
250            "aux>" => {
251                // Move top of aux stack back to main stack (Issue #350)
252                if let Some(val) = state.aux_stack.pop() {
253                    state.stack.push(val);
254                }
255            }
256
257            "tuck" => {
258                // ( a b -- b a b )
259                let b = state.pop();
260                let a = state.pop();
261                if let Some(bv) = b.clone() {
262                    state.stack.push(bv);
263                }
264                if let Some(av) = a {
265                    state.stack.push(av);
266                }
267                if let Some(bv) = b {
268                    state.stack.push(bv);
269                }
270            }
271
272            "2dup" => {
273                // ( a b -- a b a b )
274                if state.depth() >= 2 {
275                    let b = state.stack[state.depth() - 1].clone();
276                    let a = state.stack[state.depth() - 2].clone();
277                    state.stack.push(a);
278                    state.stack.push(b);
279                } else {
280                    state.push_unknown();
281                    state.push_unknown();
282                }
283            }
284
285            "3drop" => {
286                for _ in 0..3 {
287                    if let Some(StackValue::Resource(r)) = state.pop() {
288                        let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
289                        if !already_consumed {
290                            self.emit_drop_warning(&r, span, word);
291                        }
292                    }
293                }
294            }
295
296            "pick" => {
297                // ( ... n -- ... value_at_n )
298                // We can't know n statically, so just push unknown
299                state.pop(); // pop n
300                state.push_unknown();
301            }
302
303            "roll" => {
304                // Similar to pick but also removes the item
305                state.pop(); // pop n
306                state.push_unknown();
307            }
308
309            // Channel operations that don't consume
310            "chan.send" | "chan.receive" => {
311                // These use the channel but don't consume it
312                // chan.send: ( chan value -- bool )
313                // chan.receive: ( chan -- value bool )
314                state.pop();
315                state.pop();
316                state.push_unknown();
317                state.push_unknown();
318            }
319
320            // strand.spawn clones the stack to the child strand
321            // Resources on the stack are transferred to child's responsibility
322            "strand.spawn" => {
323                // Pops quotation, pushes strand-id
324                // All resources currently on stack are now shared with child
325                // Mark them as consumed since child takes responsibility
326                state.pop(); // quotation
327                let resources_on_stack: Vec<TrackedResource> = state
328                    .stack
329                    .iter()
330                    .filter_map(|v| match v {
331                        StackValue::Resource(r) => Some(r.clone()),
332                        StackValue::Unknown => None,
333                    })
334                    .collect();
335                for r in resources_on_stack {
336                    state.consume_resource(r);
337                }
338                state.push_unknown(); // strand-id
339            }
340
341            // For any other word, we don't know its stack effect
342            // Conservatively, we could assume it consumes/produces unknown values
343            // For now, we just leave the stack unchanged (may cause false positives)
344            _ => {
345                // Unknown word - could be user-defined
346                // We'd need type info to know its stack effect
347                // For Phase 2a, we'll be conservative and do nothing
348            }
349        }
350    }
351
352    /// Analyze an if/else statement
353    fn analyze_if(
354        &mut self,
355        then_branch: &[Statement],
356        else_branch: Option<&Vec<Statement>>,
357        state: &mut StackState,
358        word: &WordDef,
359    ) {
360        // Pop the condition
361        state.pop();
362
363        // Clone state for each branch
364        let mut then_state = state.clone();
365        let mut else_state = state.clone();
366
367        // Analyze then branch
368        self.analyze_statements(then_branch, &mut then_state, word);
369
370        // Analyze else branch if present
371        if let Some(else_stmts) = else_branch {
372            self.analyze_statements(else_stmts, &mut else_state, word);
373        }
374
375        // Check for inconsistent resource handling between branches
376        let merge_result = then_state.merge(&else_state);
377        for inconsistent in merge_result.inconsistent {
378            self.emit_branch_inconsistency_warning(&inconsistent, word);
379        }
380
381        // Compute proper lattice join of both branch states
382        // This ensures we track resources from either branch and only
383        // consider resources consumed if consumed in BOTH branches
384        *state = then_state.join(&else_state);
385    }
386
387    /// Analyze a match statement
388    fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
389        // Pop the matched value
390        state.pop();
391
392        if arms.is_empty() {
393            return;
394        }
395
396        // Analyze each arm
397        let mut arm_states: Vec<StackState> = Vec::new();
398
399        for arm in arms {
400            let mut arm_state = state.clone();
401
402            // Match arms may push extracted fields - for now we push unknowns
403            // based on the pattern (simplified)
404            match &arm.pattern {
405                crate::ast::Pattern::Variant(_) => {
406                    // Variant match pushes all fields - we don't know how many
407                    // so we just continue with current state
408                }
409                crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
410                    // Push unknowns for each binding
411                    for _ in bindings {
412                        arm_state.push_unknown();
413                    }
414                }
415            }
416
417            self.analyze_statements(&arm.body, &mut arm_state, word);
418            arm_states.push(arm_state);
419        }
420
421        // Check consistency between all arms
422        if arm_states.len() >= 2 {
423            let first = &arm_states[0];
424            for other in &arm_states[1..] {
425                let merge_result = first.merge(other);
426                for inconsistent in merge_result.inconsistent {
427                    self.emit_branch_inconsistency_warning(&inconsistent, word);
428                }
429            }
430        }
431
432        // Compute proper lattice join of all arm states
433        // Resources are only consumed if consumed in ALL arms
434        if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
435            *state = first;
436        }
437    }
438
439    /// Emit a warning for a resource dropped without cleanup
440    fn emit_drop_warning(
441        &mut self,
442        resource: &TrackedResource,
443        span: Option<&Span>,
444        word: &WordDef,
445    ) {
446        let line = span
447            .map(|s| s.line)
448            .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
449        let column = span.map(|s| s.column);
450
451        self.diagnostics.push(LintDiagnostic {
452            id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
453            message: format!(
454                "{} created at line {} dropped without cleanup - {}",
455                resource.kind.name(),
456                resource.created_line + 1,
457                resource.kind.cleanup_suggestion()
458            ),
459            severity: Severity::Warning,
460            replacement: String::new(),
461            file: self.file.clone(),
462            line,
463            end_line: None,
464            start_column: column,
465            end_column: column.map(|c| c + 4), // approximate
466            word_name: word.name.clone(),
467            start_index: 0,
468            end_index: 0,
469        });
470    }
471
472    /// Emit a warning for inconsistent resource handling between branches
473    fn emit_branch_inconsistency_warning(
474        &mut self,
475        inconsistent: &InconsistentResource,
476        word: &WordDef,
477    ) {
478        let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
479        let branch = if inconsistent.consumed_in_else {
480            "else"
481        } else {
482            "then"
483        };
484
485        self.diagnostics.push(LintDiagnostic {
486            id: "resource-branch-inconsistent".to_string(),
487            message: format!(
488                "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
489                inconsistent.resource.kind.name(),
490                inconsistent.resource.created_line + 1,
491                branch
492            ),
493            severity: Severity::Warning,
494            replacement: String::new(),
495            file: self.file.clone(),
496            line,
497            end_line: None,
498            start_column: None,
499            end_column: None,
500            word_name: word.name.clone(),
501            start_index: 0,
502            end_index: 0,
503        });
504    }
505}