Skip to main content

seqc/resource_lint/
program.rs

1//! Program-wide resource analyzer — does a two-pass walk that first collects
2//! each word's resource-return shape, then simulates each body with that
3//! information to catch cross-word leaks.
4
5use std::collections::HashMap;
6use std::path::Path;
7
8use crate::ast::{Program, Span, Statement, WordDef};
9use crate::lint::{LintDiagnostic, Severity};
10
11use super::state::{
12    InconsistentResource, ResourceKind, StackState, StackValue, TrackedResource, WordResourceInfo,
13};
14
15/// Program-wide resource analyzer for cross-word analysis
16///
17/// This analyzer performs two passes:
18/// 1. Collect resource information about each word (what resources it returns)
19/// 2. Analyze each word with knowledge of callee behavior
20pub struct ProgramResourceAnalyzer {
21    /// Per-word resource information (populated in first pass)
22    word_info: HashMap<String, WordResourceInfo>,
23    /// File being analyzed
24    file: std::path::PathBuf,
25    /// Diagnostics collected during analysis
26    diagnostics: Vec<LintDiagnostic>,
27}
28
29impl ProgramResourceAnalyzer {
30    pub fn new(file: &Path) -> Self {
31        ProgramResourceAnalyzer {
32            word_info: HashMap::new(),
33            file: file.to_path_buf(),
34            diagnostics: Vec::new(),
35        }
36    }
37
38    /// Analyze an entire program for resource leaks with cross-word tracking
39    pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
40        self.diagnostics.clear();
41        self.word_info.clear();
42
43        // Pass 1: Collect resource information about each word
44        for word in &program.words {
45            let info = self.collect_word_info(word);
46            self.word_info.insert(word.name.clone(), info);
47        }
48
49        // Pass 2: Analyze each word with cross-word context
50        for word in &program.words {
51            self.analyze_word_with_context(word);
52        }
53
54        std::mem::take(&mut self.diagnostics)
55    }
56
57    /// First pass: Determine what resources a word returns
58    fn collect_word_info(&self, word: &WordDef) -> WordResourceInfo {
59        let mut state = StackState::new();
60
61        // Simple analysis without emitting diagnostics
62        self.simulate_statements(&word.body, &mut state);
63
64        // Collect resource kinds remaining on stack (these are "returned")
65        let returns: Vec<ResourceKind> = state
66            .remaining_resources()
67            .into_iter()
68            .map(|r| r.kind)
69            .collect();
70
71        WordResourceInfo { returns }
72    }
73
74    /// Simulate statements to track resources (no diagnostics)
75    fn simulate_statements(&self, statements: &[Statement], state: &mut StackState) {
76        for stmt in statements {
77            self.simulate_statement(stmt, state);
78        }
79    }
80
81    /// Simulate a single statement (simplified, no diagnostics)
82    fn simulate_statement(&self, stmt: &Statement, state: &mut StackState) {
83        match stmt {
84            Statement::IntLiteral(_)
85            | Statement::FloatLiteral(_)
86            | Statement::BoolLiteral(_)
87            | Statement::StringLiteral(_)
88            | Statement::Symbol(_) => {
89                state.push_unknown();
90            }
91
92            Statement::WordCall { name, span } => {
93                self.simulate_word_call(name, span.as_ref(), state);
94            }
95
96            Statement::Quotation { .. } => {
97                state.push_unknown();
98            }
99
100            Statement::If {
101                then_branch,
102                else_branch,
103                span: _,
104            } => {
105                state.pop(); // condition
106                let mut then_state = state.clone();
107                let mut else_state = state.clone();
108                self.simulate_statements(then_branch, &mut then_state);
109                if let Some(else_stmts) = else_branch {
110                    self.simulate_statements(else_stmts, &mut else_state);
111                }
112                *state = then_state.join(&else_state);
113            }
114
115            Statement::Match { arms, span: _ } => {
116                state.pop();
117                let mut arm_states: Vec<StackState> = Vec::new();
118                for arm in arms {
119                    let mut arm_state = state.clone();
120                    self.simulate_statements(&arm.body, &mut arm_state);
121                    arm_states.push(arm_state);
122                }
123                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
124                    *state = joined;
125                }
126            }
127        }
128    }
129
130    /// Simulate common word operations shared between first and second pass.
131    ///
132    /// Returns `true` if the word was handled, `false` if the caller should
133    /// handle it (for pass-specific operations).
134    ///
135    /// The `on_resource_dropped` callback is invoked when a resource is dropped
136    /// without being consumed. The second pass uses this to emit warnings.
137    fn simulate_word_common<F>(
138        name: &str,
139        span: Option<&Span>,
140        state: &mut StackState,
141        word_info: &HashMap<String, WordResourceInfo>,
142        mut on_resource_dropped: F,
143    ) -> bool
144    where
145        F: FnMut(&TrackedResource),
146    {
147        let line = span.map(|s| s.line).unwrap_or(0);
148
149        match name {
150            // Resource-creating builtins
151            "strand.weave" => {
152                state.pop();
153                state.push_resource(ResourceKind::WeaveHandle, line, name);
154            }
155            "chan.make" => {
156                state.push_resource(ResourceKind::Channel, line, name);
157            }
158
159            // Resource-consuming builtins
160            "strand.weave-cancel" => {
161                if let Some(StackValue::Resource(r)) = state.pop()
162                    && r.kind == ResourceKind::WeaveHandle
163                {
164                    state.consume_resource(r);
165                }
166            }
167            "chan.close" => {
168                if let Some(StackValue::Resource(r)) = state.pop()
169                    && r.kind == ResourceKind::Channel
170                {
171                    state.consume_resource(r);
172                }
173            }
174
175            // Stack operations
176            "drop" => {
177                let dropped = state.pop();
178                if let Some(StackValue::Resource(r)) = dropped {
179                    // Check if already consumed (e.g., via strand.spawn)
180                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
181                    if !already_consumed {
182                        on_resource_dropped(&r);
183                    }
184                }
185            }
186            "dup" => {
187                // Only duplicate if there's something on the stack
188                // Don't push unknown on empty - maintains original first-pass behavior
189                if let Some(top) = state.peek().cloned() {
190                    state.stack.push(top);
191                }
192            }
193            "swap" => {
194                let a = state.pop();
195                let b = state.pop();
196                if let Some(av) = a {
197                    state.stack.push(av);
198                }
199                if let Some(bv) = b {
200                    state.stack.push(bv);
201                }
202            }
203            "over" => {
204                // ( ..a x y -- ..a x y x )
205                if state.depth() >= 2 {
206                    let second = state.stack[state.depth() - 2].clone();
207                    state.stack.push(second);
208                }
209            }
210            "rot" => {
211                // ( ..a x y z -- ..a y z x )
212                let c = state.pop();
213                let b = state.pop();
214                let a = state.pop();
215                if let Some(bv) = b {
216                    state.stack.push(bv);
217                }
218                if let Some(cv) = c {
219                    state.stack.push(cv);
220                }
221                if let Some(av) = a {
222                    state.stack.push(av);
223                }
224            }
225            "nip" => {
226                // ( ..a x y -- ..a y ) - drops x, which may be a resource
227                let b = state.pop();
228                let a = state.pop();
229                if let Some(StackValue::Resource(r)) = a {
230                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
231                    if !already_consumed {
232                        on_resource_dropped(&r);
233                    }
234                }
235                if let Some(bv) = b {
236                    state.stack.push(bv);
237                }
238            }
239            ">aux" => {
240                // Move top of main stack to aux stack (Issue #350)
241                if let Some(val) = state.pop() {
242                    state.aux_stack.push(val);
243                }
244            }
245            "aux>" => {
246                // Move top of aux stack back to main stack (Issue #350)
247                if let Some(val) = state.aux_stack.pop() {
248                    state.stack.push(val);
249                }
250            }
251            "tuck" => {
252                // ( ..a x y -- ..a y x y )
253                let b = state.pop();
254                let a = state.pop();
255                if let Some(bv) = b.clone() {
256                    state.stack.push(bv);
257                }
258                if let Some(av) = a {
259                    state.stack.push(av);
260                }
261                if let Some(bv) = b {
262                    state.stack.push(bv);
263                }
264            }
265
266            // strand.spawn transfers resources
267            "strand.spawn" => {
268                state.pop();
269                let resources: Vec<TrackedResource> = state
270                    .stack
271                    .iter()
272                    .filter_map(|v| match v {
273                        StackValue::Resource(r) => Some(r.clone()),
274                        StackValue::Unknown => None,
275                    })
276                    .collect();
277                for r in resources {
278                    state.consume_resource(r);
279                }
280                state.push_unknown();
281            }
282
283            // Map operations that store values safely
284            "map.set" => {
285                // ( map key value -- map' ) - value is stored in map
286                let value = state.pop();
287                state.pop(); // key
288                state.pop(); // map
289                // Value is now safely stored in the map - consume if resource
290                if let Some(StackValue::Resource(r)) = value {
291                    state.consume_resource(r);
292                }
293                state.push_unknown(); // map'
294            }
295
296            // List operations that store values safely
297            "list.push" | "list.prepend" => {
298                // ( list value -- list' ) - value is stored in list
299                let value = state.pop();
300                state.pop(); // list
301                if let Some(StackValue::Resource(r)) = value {
302                    state.consume_resource(r);
303                }
304                state.push_unknown(); // list'
305            }
306
307            // User-defined words - check if we have info about them
308            _ => {
309                if let Some(info) = word_info.get(name) {
310                    // Push resources that this word returns
311                    for kind in &info.returns {
312                        state.push_resource(*kind, line, name);
313                    }
314                    return true;
315                }
316                // Not handled - caller should handle pass-specific operations
317                return false;
318            }
319        }
320        true
321    }
322
323    /// Simulate a word call (for first pass)
324    fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
325        // First pass uses shared logic with no-op callback for dropped resources
326        Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
327    }
328
329    /// Second pass: Analyze a word with full cross-word context
330    fn analyze_word_with_context(&mut self, word: &WordDef) {
331        let mut state = StackState::new();
332
333        self.analyze_statements_with_context(&word.body, &mut state, word);
334
335        // Resources on stack at end are returned - no warning (escape analysis)
336    }
337
338    /// Analyze statements with diagnostics and cross-word tracking
339    fn analyze_statements_with_context(
340        &mut self,
341        statements: &[Statement],
342        state: &mut StackState,
343        word: &WordDef,
344    ) {
345        for stmt in statements {
346            self.analyze_statement_with_context(stmt, state, word);
347        }
348    }
349
350    /// Analyze a single statement with cross-word context
351    fn analyze_statement_with_context(
352        &mut self,
353        stmt: &Statement,
354        state: &mut StackState,
355        word: &WordDef,
356    ) {
357        match stmt {
358            Statement::IntLiteral(_)
359            | Statement::FloatLiteral(_)
360            | Statement::BoolLiteral(_)
361            | Statement::StringLiteral(_)
362            | Statement::Symbol(_) => {
363                state.push_unknown();
364            }
365
366            Statement::WordCall { name, span } => {
367                self.analyze_word_call_with_context(name, span.as_ref(), state, word);
368            }
369
370            Statement::Quotation { .. } => {
371                state.push_unknown();
372            }
373
374            Statement::If {
375                then_branch,
376                else_branch,
377                span: _,
378            } => {
379                state.pop();
380                let mut then_state = state.clone();
381                let mut else_state = state.clone();
382
383                self.analyze_statements_with_context(then_branch, &mut then_state, word);
384                if let Some(else_stmts) = else_branch {
385                    self.analyze_statements_with_context(else_stmts, &mut else_state, word);
386                }
387
388                // Check for inconsistent handling
389                let merge_result = then_state.merge(&else_state);
390                for inconsistent in merge_result.inconsistent {
391                    self.emit_branch_inconsistency_warning(&inconsistent, word);
392                }
393
394                *state = then_state.join(&else_state);
395            }
396
397            Statement::Match { arms, span: _ } => {
398                state.pop();
399                let mut arm_states: Vec<StackState> = Vec::new();
400
401                for arm in arms {
402                    let mut arm_state = state.clone();
403                    self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
404                    arm_states.push(arm_state);
405                }
406
407                // Check consistency
408                if arm_states.len() >= 2 {
409                    let first = &arm_states[0];
410                    for other in &arm_states[1..] {
411                        let merge_result = first.merge(other);
412                        for inconsistent in merge_result.inconsistent {
413                            self.emit_branch_inconsistency_warning(&inconsistent, word);
414                        }
415                    }
416                }
417
418                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
419                    *state = joined;
420                }
421            }
422        }
423    }
424
425    /// Analyze a word call with cross-word tracking
426    fn analyze_word_call_with_context(
427        &mut self,
428        name: &str,
429        span: Option<&Span>,
430        state: &mut StackState,
431        word: &WordDef,
432    ) {
433        // Collect dropped resources to emit warnings after shared simulation
434        let mut dropped_resources: Vec<TrackedResource> = Vec::new();
435
436        // Try shared logic first
437        let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
438            dropped_resources.push(r.clone())
439        });
440
441        // Emit warnings for any resources dropped without cleanup
442        for r in dropped_resources {
443            self.emit_drop_warning(&r, span, word);
444        }
445
446        if handled {
447            return;
448        }
449
450        // Handle operations unique to the second pass
451        match name {
452            // strand.resume handling - can't be shared because it has complex stack behavior
453            "strand.resume" => {
454                let value = state.pop();
455                let handle = state.pop();
456                if let Some(h) = handle {
457                    state.stack.push(h);
458                } else {
459                    state.push_unknown();
460                }
461                if let Some(v) = value {
462                    state.stack.push(v);
463                } else {
464                    state.push_unknown();
465                }
466                state.push_unknown();
467            }
468
469            "2dup" => {
470                if state.depth() >= 2 {
471                    let b = state.stack[state.depth() - 1].clone();
472                    let a = state.stack[state.depth() - 2].clone();
473                    state.stack.push(a);
474                    state.stack.push(b);
475                } else {
476                    state.push_unknown();
477                    state.push_unknown();
478                }
479            }
480
481            "3drop" => {
482                for _ in 0..3 {
483                    if let Some(StackValue::Resource(r)) = state.pop() {
484                        let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
485                        if !already_consumed {
486                            self.emit_drop_warning(&r, span, word);
487                        }
488                    }
489                }
490            }
491
492            "pick" | "roll" => {
493                state.pop();
494                state.push_unknown();
495            }
496
497            "chan.send" | "chan.receive" => {
498                state.pop();
499                state.pop();
500                state.push_unknown();
501                state.push_unknown();
502            }
503
504            // Unknown words: leave stack unchanged (may cause false negatives)
505            _ => {}
506        }
507    }
508
509    fn emit_drop_warning(
510        &mut self,
511        resource: &TrackedResource,
512        span: Option<&Span>,
513        word: &WordDef,
514    ) {
515        let line = span
516            .map(|s| s.line)
517            .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
518        let column = span.map(|s| s.column);
519
520        self.diagnostics.push(LintDiagnostic {
521            id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
522            message: format!(
523                "{} from `{}` (line {}) dropped without cleanup - {}",
524                resource.kind.name(),
525                resource.created_by,
526                resource.created_line + 1,
527                resource.kind.cleanup_suggestion()
528            ),
529            severity: Severity::Warning,
530            replacement: String::new(),
531            file: self.file.clone(),
532            line,
533            end_line: None,
534            start_column: column,
535            end_column: column.map(|c| c + 4),
536            word_name: word.name.clone(),
537            start_index: 0,
538            end_index: 0,
539        });
540    }
541
542    fn emit_branch_inconsistency_warning(
543        &mut self,
544        inconsistent: &InconsistentResource,
545        word: &WordDef,
546    ) {
547        let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
548        let branch = if inconsistent.consumed_in_else {
549            "else"
550        } else {
551            "then"
552        };
553
554        self.diagnostics.push(LintDiagnostic {
555            id: "resource-branch-inconsistent".to_string(),
556            message: format!(
557                "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
558                inconsistent.resource.kind.name(),
559                inconsistent.resource.created_by,
560                inconsistent.resource.created_line + 1,
561                branch
562            ),
563            severity: Severity::Warning,
564            replacement: String::new(),
565            file: self.file.clone(),
566            line,
567            end_line: None,
568            start_column: None,
569            end_column: None,
570            word_name: word.name.clone(),
571            start_index: 0,
572            end_index: 0,
573        });
574    }
575}