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                let merge_result = then_state.merge(&else_state);
389                for inconsistent in merge_result.inconsistent {
390                    self.emit_branch_inconsistency_warning(&inconsistent, word);
391                }
392
393                *state = then_state.join(&else_state);
394            }
395
396            Statement::Match { arms, span: _ } => {
397                state.pop();
398                let mut arm_states: Vec<StackState> = Vec::new();
399
400                for arm in arms {
401                    let mut arm_state = state.clone();
402                    self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
403                    arm_states.push(arm_state);
404                }
405
406                if arm_states.len() >= 2 {
407                    let first = &arm_states[0];
408                    for other in &arm_states[1..] {
409                        let merge_result = first.merge(other);
410                        for inconsistent in merge_result.inconsistent {
411                            self.emit_branch_inconsistency_warning(&inconsistent, word);
412                        }
413                    }
414                }
415
416                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
417                    *state = joined;
418                }
419            }
420        }
421    }
422
423    /// Analyze a word call with cross-word tracking
424    fn analyze_word_call_with_context(
425        &mut self,
426        name: &str,
427        span: Option<&Span>,
428        state: &mut StackState,
429        word: &WordDef,
430    ) {
431        // Collect dropped resources to emit warnings after shared simulation
432        let mut dropped_resources: Vec<TrackedResource> = Vec::new();
433
434        // Try shared logic first
435        let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
436            dropped_resources.push(r.clone())
437        });
438
439        for r in dropped_resources {
440            self.emit_drop_warning(&r, span, word);
441        }
442
443        if handled {
444            return;
445        }
446
447        // Handle operations unique to the second pass
448        match name {
449            // strand.resume handling - can't be shared because it has complex stack behavior
450            "strand.resume" => {
451                let value = state.pop();
452                let handle = state.pop();
453                if let Some(h) = handle {
454                    state.stack.push(h);
455                } else {
456                    state.push_unknown();
457                }
458                if let Some(v) = value {
459                    state.stack.push(v);
460                } else {
461                    state.push_unknown();
462                }
463                state.push_unknown();
464            }
465
466            "2dup" => {
467                if state.depth() >= 2 {
468                    let b = state.stack[state.depth() - 1].clone();
469                    let a = state.stack[state.depth() - 2].clone();
470                    state.stack.push(a);
471                    state.stack.push(b);
472                } else {
473                    state.push_unknown();
474                    state.push_unknown();
475                }
476            }
477
478            "3drop" => {
479                for _ in 0..3 {
480                    if let Some(StackValue::Resource(r)) = state.pop() {
481                        let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
482                        if !already_consumed {
483                            self.emit_drop_warning(&r, span, word);
484                        }
485                    }
486                }
487            }
488
489            "pick" | "roll" => {
490                state.pop();
491                state.push_unknown();
492            }
493
494            "chan.send" | "chan.receive" => {
495                state.pop();
496                state.pop();
497                state.push_unknown();
498                state.push_unknown();
499            }
500
501            // Unknown words: leave stack unchanged (may cause false negatives)
502            _ => {}
503        }
504    }
505
506    fn emit_drop_warning(
507        &mut self,
508        resource: &TrackedResource,
509        span: Option<&Span>,
510        word: &WordDef,
511    ) {
512        let line = span
513            .map(|s| s.line)
514            .or_else(|| word.source.as_ref().map(|s| s.start_line))
515            .unwrap_or(0);
516        let column = span.map(|s| s.column);
517
518        self.diagnostics.push(LintDiagnostic {
519            id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
520            message: format!(
521                "{} from `{}` (line {}) dropped without cleanup - {}",
522                resource.kind.name(),
523                resource.created_by,
524                resource.created_line + 1,
525                resource.kind.cleanup_suggestion()
526            ),
527            severity: Severity::Warning,
528            replacement: String::new(),
529            file: self.file.clone(),
530            line,
531            end_line: None,
532            start_column: column,
533            end_column: column.map(|c| c + 4),
534            word_name: word.name.clone(),
535            start_index: 0,
536            end_index: 0,
537        });
538    }
539
540    fn emit_branch_inconsistency_warning(
541        &mut self,
542        inconsistent: &InconsistentResource,
543        word: &WordDef,
544    ) {
545        let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
546        let branch = if inconsistent.consumed_in_else {
547            "else"
548        } else {
549            "then"
550        };
551
552        self.diagnostics.push(LintDiagnostic {
553            id: "resource-branch-inconsistent".to_string(),
554            message: format!(
555                "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
556                inconsistent.resource.kind.name(),
557                inconsistent.resource.created_by,
558                inconsistent.resource.created_line + 1,
559                branch
560            ),
561            severity: Severity::Warning,
562            replacement: String::new(),
563            file: self.file.clone(),
564            line,
565            end_line: None,
566            start_column: None,
567            end_column: None,
568            word_name: word.name.clone(),
569            start_index: 0,
570            end_index: 0,
571        });
572    }
573}