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}