1use crate::ast::{MatchArm, Program, Span, Statement, WordDef};
37use crate::lint::{LintDiagnostic, Severity};
38use std::collections::HashMap;
39use std::path::Path;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub(crate) enum ResourceKind {
44 WeaveHandle,
46 Channel,
48}
49
50impl ResourceKind {
51 fn name(&self) -> &'static str {
52 match self {
53 ResourceKind::WeaveHandle => "WeaveHandle",
54 ResourceKind::Channel => "Channel",
55 }
56 }
57
58 fn cleanup_suggestion(&self) -> &'static str {
59 match self {
60 ResourceKind::WeaveHandle => "use `strand.weave-cancel` or resume to completion",
61 ResourceKind::Channel => "use `chan.close` when done",
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub(crate) struct TrackedResource {
69 pub kind: ResourceKind,
71 pub id: usize,
73 pub created_line: usize,
75 pub created_by: String,
77}
78
79#[derive(Debug, Clone)]
81pub(crate) enum StackValue {
82 Resource(TrackedResource),
84 Unknown,
86}
87
88#[derive(Debug, Clone)]
90pub(crate) struct StackState {
91 stack: Vec<StackValue>,
93 aux_stack: Vec<StackValue>,
95 consumed: Vec<TrackedResource>,
97 next_id: usize,
99}
100
101impl Default for StackState {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl StackState {
108 pub fn new() -> Self {
109 StackState {
110 stack: Vec::new(),
111 aux_stack: Vec::new(),
112 consumed: Vec::new(),
113 next_id: 0,
114 }
115 }
116
117 pub fn push_unknown(&mut self) {
119 self.stack.push(StackValue::Unknown);
120 }
121
122 pub fn push_resource(&mut self, kind: ResourceKind, line: usize, word: &str) {
124 let resource = TrackedResource {
125 kind,
126 id: self.next_id,
127 created_line: line,
128 created_by: word.to_string(),
129 };
130 self.next_id += 1;
131 self.stack.push(StackValue::Resource(resource));
132 }
133
134 pub fn pop(&mut self) -> Option<StackValue> {
136 self.stack.pop()
137 }
138
139 pub fn peek(&self) -> Option<&StackValue> {
141 self.stack.last()
142 }
143
144 pub fn depth(&self) -> usize {
146 self.stack.len()
147 }
148
149 pub fn consume_resource(&mut self, resource: TrackedResource) {
151 self.consumed.push(resource);
152 }
153
154 pub fn remaining_resources(&self) -> Vec<&TrackedResource> {
156 self.stack
157 .iter()
158 .filter_map(|v| match v {
159 StackValue::Resource(r) => Some(r),
160 StackValue::Unknown => None,
161 })
162 .collect()
163 }
164
165 pub fn merge(&self, other: &StackState) -> BranchMergeResult {
168 let self_resources: HashMap<usize, &TrackedResource> = self
169 .stack
170 .iter()
171 .filter_map(|v| match v {
172 StackValue::Resource(r) => Some((r.id, r)),
173 StackValue::Unknown => None,
174 })
175 .collect();
176
177 let other_resources: HashMap<usize, &TrackedResource> = other
178 .stack
179 .iter()
180 .filter_map(|v| match v {
181 StackValue::Resource(r) => Some((r.id, r)),
182 StackValue::Unknown => None,
183 })
184 .collect();
185
186 let self_consumed: std::collections::HashSet<usize> =
187 self.consumed.iter().map(|r| r.id).collect();
188 let other_consumed: std::collections::HashSet<usize> =
189 other.consumed.iter().map(|r| r.id).collect();
190
191 let mut inconsistent = Vec::new();
192
193 for (id, resource) in &self_resources {
195 if other_consumed.contains(id) && !self_consumed.contains(id) {
196 inconsistent.push(InconsistentResource {
198 resource: (*resource).clone(),
199 consumed_in_else: true,
200 });
201 }
202 }
203
204 for (id, resource) in &other_resources {
205 if self_consumed.contains(id) && !other_consumed.contains(id) {
206 inconsistent.push(InconsistentResource {
208 resource: (*resource).clone(),
209 consumed_in_else: false,
210 });
211 }
212 }
213
214 BranchMergeResult { inconsistent }
215 }
216
217 pub fn join(&self, other: &StackState) -> StackState {
226 let other_consumed: std::collections::HashSet<usize> =
228 other.consumed.iter().map(|r| r.id).collect();
229
230 let definitely_consumed: Vec<TrackedResource> = self
232 .consumed
233 .iter()
234 .filter(|r| other_consumed.contains(&r.id))
235 .cloned()
236 .collect();
237
238 let mut joined_stack = self.stack.clone();
246
247 let other_resources: HashMap<usize, TrackedResource> = other
249 .stack
250 .iter()
251 .filter_map(|v| match v {
252 StackValue::Resource(r) => Some((r.id, r.clone())),
253 StackValue::Unknown => None,
254 })
255 .collect();
256
257 for (i, val) in joined_stack.iter_mut().enumerate() {
259 if matches!(val, StackValue::Unknown)
260 && i < other.stack.len()
261 && let StackValue::Resource(r) = &other.stack[i]
262 {
263 *val = StackValue::Resource(r.clone());
264 }
265 }
266
267 let self_resource_ids: std::collections::HashSet<usize> = joined_stack
270 .iter()
271 .filter_map(|v| match v {
272 StackValue::Resource(r) => Some(r.id),
273 StackValue::Unknown => None,
274 })
275 .collect();
276
277 for (id, resource) in other_resources {
278 if !self_resource_ids.contains(&id) && !definitely_consumed.iter().any(|r| r.id == id) {
279 joined_stack.push(StackValue::Resource(resource));
282 }
283 }
284
285 let joined_aux = if self.aux_stack.len() >= other.aux_stack.len() {
287 self.aux_stack.clone()
288 } else {
289 other.aux_stack.clone()
290 };
291
292 StackState {
293 stack: joined_stack,
294 aux_stack: joined_aux,
295 consumed: definitely_consumed,
296 next_id: self.next_id.max(other.next_id),
297 }
298 }
299}
300
301#[derive(Debug)]
303pub(crate) struct BranchMergeResult {
304 pub inconsistent: Vec<InconsistentResource>,
306}
307
308#[derive(Debug)]
310pub(crate) struct InconsistentResource {
311 pub resource: TrackedResource,
312 pub consumed_in_else: bool,
314}
315
316#[derive(Debug, Clone, Default)]
322pub(crate) struct WordResourceInfo {
323 pub returns: Vec<ResourceKind>,
325}
326
327pub struct ProgramResourceAnalyzer {
333 word_info: HashMap<String, WordResourceInfo>,
335 file: std::path::PathBuf,
337 diagnostics: Vec<LintDiagnostic>,
339}
340
341impl ProgramResourceAnalyzer {
342 pub fn new(file: &Path) -> Self {
343 ProgramResourceAnalyzer {
344 word_info: HashMap::new(),
345 file: file.to_path_buf(),
346 diagnostics: Vec::new(),
347 }
348 }
349
350 pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
352 self.diagnostics.clear();
353 self.word_info.clear();
354
355 for word in &program.words {
357 let info = self.collect_word_info(word);
358 self.word_info.insert(word.name.clone(), info);
359 }
360
361 for word in &program.words {
363 self.analyze_word_with_context(word);
364 }
365
366 std::mem::take(&mut self.diagnostics)
367 }
368
369 fn collect_word_info(&self, word: &WordDef) -> WordResourceInfo {
371 let mut state = StackState::new();
372
373 self.simulate_statements(&word.body, &mut state);
375
376 let returns: Vec<ResourceKind> = state
378 .remaining_resources()
379 .into_iter()
380 .map(|r| r.kind)
381 .collect();
382
383 WordResourceInfo { returns }
384 }
385
386 fn simulate_statements(&self, statements: &[Statement], state: &mut StackState) {
388 for stmt in statements {
389 self.simulate_statement(stmt, state);
390 }
391 }
392
393 fn simulate_statement(&self, stmt: &Statement, state: &mut StackState) {
395 match stmt {
396 Statement::IntLiteral(_)
397 | Statement::FloatLiteral(_)
398 | Statement::BoolLiteral(_)
399 | Statement::StringLiteral(_)
400 | Statement::Symbol(_) => {
401 state.push_unknown();
402 }
403
404 Statement::WordCall { name, span } => {
405 self.simulate_word_call(name, span.as_ref(), state);
406 }
407
408 Statement::Quotation { .. } => {
409 state.push_unknown();
410 }
411
412 Statement::If {
413 then_branch,
414 else_branch,
415 span: _,
416 } => {
417 state.pop(); let mut then_state = state.clone();
419 let mut else_state = state.clone();
420 self.simulate_statements(then_branch, &mut then_state);
421 if let Some(else_stmts) = else_branch {
422 self.simulate_statements(else_stmts, &mut else_state);
423 }
424 *state = then_state.join(&else_state);
425 }
426
427 Statement::Match { arms, span: _ } => {
428 state.pop();
429 let mut arm_states: Vec<StackState> = Vec::new();
430 for arm in arms {
431 let mut arm_state = state.clone();
432 self.simulate_statements(&arm.body, &mut arm_state);
433 arm_states.push(arm_state);
434 }
435 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
436 *state = joined;
437 }
438 }
439 }
440 }
441
442 fn simulate_word_common<F>(
450 name: &str,
451 span: Option<&Span>,
452 state: &mut StackState,
453 word_info: &HashMap<String, WordResourceInfo>,
454 mut on_resource_dropped: F,
455 ) -> bool
456 where
457 F: FnMut(&TrackedResource),
458 {
459 let line = span.map(|s| s.line).unwrap_or(0);
460
461 match name {
462 "strand.weave" => {
464 state.pop();
465 state.push_resource(ResourceKind::WeaveHandle, line, name);
466 }
467 "chan.make" => {
468 state.push_resource(ResourceKind::Channel, line, name);
469 }
470
471 "strand.weave-cancel" => {
473 if let Some(StackValue::Resource(r)) = state.pop()
474 && r.kind == ResourceKind::WeaveHandle
475 {
476 state.consume_resource(r);
477 }
478 }
479 "chan.close" => {
480 if let Some(StackValue::Resource(r)) = state.pop()
481 && r.kind == ResourceKind::Channel
482 {
483 state.consume_resource(r);
484 }
485 }
486
487 "drop" => {
489 let dropped = state.pop();
490 if let Some(StackValue::Resource(r)) = dropped {
491 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
493 if !already_consumed {
494 on_resource_dropped(&r);
495 }
496 }
497 }
498 "dup" => {
499 if let Some(top) = state.peek().cloned() {
502 state.stack.push(top);
503 }
504 }
505 "swap" => {
506 let a = state.pop();
507 let b = state.pop();
508 if let Some(av) = a {
509 state.stack.push(av);
510 }
511 if let Some(bv) = b {
512 state.stack.push(bv);
513 }
514 }
515 "over" => {
516 if state.depth() >= 2 {
518 let second = state.stack[state.depth() - 2].clone();
519 state.stack.push(second);
520 }
521 }
522 "rot" => {
523 let c = state.pop();
525 let b = state.pop();
526 let a = state.pop();
527 if let Some(bv) = b {
528 state.stack.push(bv);
529 }
530 if let Some(cv) = c {
531 state.stack.push(cv);
532 }
533 if let Some(av) = a {
534 state.stack.push(av);
535 }
536 }
537 "nip" => {
538 let b = state.pop();
540 let a = state.pop();
541 if let Some(StackValue::Resource(r)) = a {
542 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
543 if !already_consumed {
544 on_resource_dropped(&r);
545 }
546 }
547 if let Some(bv) = b {
548 state.stack.push(bv);
549 }
550 }
551 ">aux" => {
552 if let Some(val) = state.pop() {
554 state.aux_stack.push(val);
555 }
556 }
557 "aux>" => {
558 if let Some(val) = state.aux_stack.pop() {
560 state.stack.push(val);
561 }
562 }
563 "tuck" => {
564 let b = state.pop();
566 let a = state.pop();
567 if let Some(bv) = b.clone() {
568 state.stack.push(bv);
569 }
570 if let Some(av) = a {
571 state.stack.push(av);
572 }
573 if let Some(bv) = b {
574 state.stack.push(bv);
575 }
576 }
577
578 "strand.spawn" => {
580 state.pop();
581 let resources: Vec<TrackedResource> = state
582 .stack
583 .iter()
584 .filter_map(|v| match v {
585 StackValue::Resource(r) => Some(r.clone()),
586 StackValue::Unknown => None,
587 })
588 .collect();
589 for r in resources {
590 state.consume_resource(r);
591 }
592 state.push_unknown();
593 }
594
595 "map.set" => {
597 let value = state.pop();
599 state.pop(); state.pop(); if let Some(StackValue::Resource(r)) = value {
603 state.consume_resource(r);
604 }
605 state.push_unknown(); }
607
608 "list.push" | "list.prepend" => {
610 let value = state.pop();
612 state.pop(); if let Some(StackValue::Resource(r)) = value {
614 state.consume_resource(r);
615 }
616 state.push_unknown(); }
618
619 _ => {
621 if let Some(info) = word_info.get(name) {
622 for kind in &info.returns {
624 state.push_resource(*kind, line, name);
625 }
626 return true;
627 }
628 return false;
630 }
631 }
632 true
633 }
634
635 fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
637 Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
639 }
640
641 fn analyze_word_with_context(&mut self, word: &WordDef) {
643 let mut state = StackState::new();
644
645 self.analyze_statements_with_context(&word.body, &mut state, word);
646
647 }
649
650 fn analyze_statements_with_context(
652 &mut self,
653 statements: &[Statement],
654 state: &mut StackState,
655 word: &WordDef,
656 ) {
657 for stmt in statements {
658 self.analyze_statement_with_context(stmt, state, word);
659 }
660 }
661
662 fn analyze_statement_with_context(
664 &mut self,
665 stmt: &Statement,
666 state: &mut StackState,
667 word: &WordDef,
668 ) {
669 match stmt {
670 Statement::IntLiteral(_)
671 | Statement::FloatLiteral(_)
672 | Statement::BoolLiteral(_)
673 | Statement::StringLiteral(_)
674 | Statement::Symbol(_) => {
675 state.push_unknown();
676 }
677
678 Statement::WordCall { name, span } => {
679 self.analyze_word_call_with_context(name, span.as_ref(), state, word);
680 }
681
682 Statement::Quotation { .. } => {
683 state.push_unknown();
684 }
685
686 Statement::If {
687 then_branch,
688 else_branch,
689 span: _,
690 } => {
691 state.pop();
692 let mut then_state = state.clone();
693 let mut else_state = state.clone();
694
695 self.analyze_statements_with_context(then_branch, &mut then_state, word);
696 if let Some(else_stmts) = else_branch {
697 self.analyze_statements_with_context(else_stmts, &mut else_state, word);
698 }
699
700 let merge_result = then_state.merge(&else_state);
702 for inconsistent in merge_result.inconsistent {
703 self.emit_branch_inconsistency_warning(&inconsistent, word);
704 }
705
706 *state = then_state.join(&else_state);
707 }
708
709 Statement::Match { arms, span: _ } => {
710 state.pop();
711 let mut arm_states: Vec<StackState> = Vec::new();
712
713 for arm in arms {
714 let mut arm_state = state.clone();
715 self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
716 arm_states.push(arm_state);
717 }
718
719 if arm_states.len() >= 2 {
721 let first = &arm_states[0];
722 for other in &arm_states[1..] {
723 let merge_result = first.merge(other);
724 for inconsistent in merge_result.inconsistent {
725 self.emit_branch_inconsistency_warning(&inconsistent, word);
726 }
727 }
728 }
729
730 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
731 *state = joined;
732 }
733 }
734 }
735 }
736
737 fn analyze_word_call_with_context(
739 &mut self,
740 name: &str,
741 span: Option<&Span>,
742 state: &mut StackState,
743 word: &WordDef,
744 ) {
745 let mut dropped_resources: Vec<TrackedResource> = Vec::new();
747
748 let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
750 dropped_resources.push(r.clone())
751 });
752
753 for r in dropped_resources {
755 self.emit_drop_warning(&r, span, word);
756 }
757
758 if handled {
759 return;
760 }
761
762 match name {
764 "strand.resume" => {
766 let value = state.pop();
767 let handle = state.pop();
768 if let Some(h) = handle {
769 state.stack.push(h);
770 } else {
771 state.push_unknown();
772 }
773 if let Some(v) = value {
774 state.stack.push(v);
775 } else {
776 state.push_unknown();
777 }
778 state.push_unknown();
779 }
780
781 "2dup" => {
782 if state.depth() >= 2 {
783 let b = state.stack[state.depth() - 1].clone();
784 let a = state.stack[state.depth() - 2].clone();
785 state.stack.push(a);
786 state.stack.push(b);
787 } else {
788 state.push_unknown();
789 state.push_unknown();
790 }
791 }
792
793 "3drop" => {
794 for _ in 0..3 {
795 if let Some(StackValue::Resource(r)) = state.pop() {
796 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
797 if !already_consumed {
798 self.emit_drop_warning(&r, span, word);
799 }
800 }
801 }
802 }
803
804 "pick" | "roll" => {
805 state.pop();
806 state.push_unknown();
807 }
808
809 "chan.send" | "chan.receive" => {
810 state.pop();
811 state.pop();
812 state.push_unknown();
813 state.push_unknown();
814 }
815
816 _ => {}
818 }
819 }
820
821 fn emit_drop_warning(
822 &mut self,
823 resource: &TrackedResource,
824 span: Option<&Span>,
825 word: &WordDef,
826 ) {
827 let line = span
828 .map(|s| s.line)
829 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
830 let column = span.map(|s| s.column);
831
832 self.diagnostics.push(LintDiagnostic {
833 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
834 message: format!(
835 "{} from `{}` (line {}) dropped without cleanup - {}",
836 resource.kind.name(),
837 resource.created_by,
838 resource.created_line + 1,
839 resource.kind.cleanup_suggestion()
840 ),
841 severity: Severity::Warning,
842 replacement: String::new(),
843 file: self.file.clone(),
844 line,
845 end_line: None,
846 start_column: column,
847 end_column: column.map(|c| c + 4),
848 word_name: word.name.clone(),
849 start_index: 0,
850 end_index: 0,
851 });
852 }
853
854 fn emit_branch_inconsistency_warning(
855 &mut self,
856 inconsistent: &InconsistentResource,
857 word: &WordDef,
858 ) {
859 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
860 let branch = if inconsistent.consumed_in_else {
861 "else"
862 } else {
863 "then"
864 };
865
866 self.diagnostics.push(LintDiagnostic {
867 id: "resource-branch-inconsistent".to_string(),
868 message: format!(
869 "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
870 inconsistent.resource.kind.name(),
871 inconsistent.resource.created_by,
872 inconsistent.resource.created_line + 1,
873 branch
874 ),
875 severity: Severity::Warning,
876 replacement: String::new(),
877 file: self.file.clone(),
878 line,
879 end_line: None,
880 start_column: None,
881 end_column: None,
882 word_name: word.name.clone(),
883 start_index: 0,
884 end_index: 0,
885 });
886 }
887}
888
889pub struct ResourceAnalyzer {
891 diagnostics: Vec<LintDiagnostic>,
893 file: std::path::PathBuf,
895}
896
897impl ResourceAnalyzer {
898 pub fn new(file: &Path) -> Self {
899 ResourceAnalyzer {
900 diagnostics: Vec::new(),
901 file: file.to_path_buf(),
902 }
903 }
904
905 pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
907 self.diagnostics.clear();
908
909 let mut state = StackState::new();
910
911 self.analyze_statements(&word.body, &mut state, word);
913
914 let _ = state.remaining_resources(); std::mem::take(&mut self.diagnostics)
930 }
931
932 fn analyze_statements(
934 &mut self,
935 statements: &[Statement],
936 state: &mut StackState,
937 word: &WordDef,
938 ) {
939 for stmt in statements {
940 self.analyze_statement(stmt, state, word);
941 }
942 }
943
944 fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
946 match stmt {
947 Statement::IntLiteral(_)
948 | Statement::FloatLiteral(_)
949 | Statement::BoolLiteral(_)
950 | Statement::StringLiteral(_)
951 | Statement::Symbol(_) => {
952 state.push_unknown();
953 }
954
955 Statement::WordCall { name, span } => {
956 self.analyze_word_call(name, span.as_ref(), state, word);
957 }
958
959 Statement::Quotation { body, .. } => {
960 let _ = body; state.push_unknown();
966 }
967
968 Statement::If {
969 then_branch,
970 else_branch,
971 span: _,
972 } => {
973 self.analyze_if(then_branch, else_branch.as_ref(), state, word);
974 }
975
976 Statement::Match { arms, span: _ } => {
977 self.analyze_match(arms, state, word);
978 }
979 }
980 }
981
982 fn analyze_word_call(
984 &mut self,
985 name: &str,
986 span: Option<&Span>,
987 state: &mut StackState,
988 word: &WordDef,
989 ) {
990 let line = span.map(|s| s.line).unwrap_or(0);
991
992 match name {
993 "strand.weave" => {
995 state.pop(); state.push_resource(ResourceKind::WeaveHandle, line, name);
998 }
999
1000 "chan.make" => {
1001 state.push_resource(ResourceKind::Channel, line, name);
1003 }
1004
1005 "strand.weave-cancel" => {
1007 if let Some(StackValue::Resource(r)) = state.pop()
1009 && r.kind == ResourceKind::WeaveHandle
1010 {
1011 state.consume_resource(r);
1012 }
1013 }
1014
1015 "chan.close" => {
1016 if let Some(StackValue::Resource(r)) = state.pop()
1018 && r.kind == ResourceKind::Channel
1019 {
1020 state.consume_resource(r);
1021 }
1022 }
1023
1024 "strand.resume" => {
1029 let value = state.pop(); let handle = state.pop(); if let Some(h) = handle {
1035 state.stack.push(h);
1036 } else {
1037 state.push_unknown();
1038 }
1039 if let Some(v) = value {
1040 state.stack.push(v);
1041 } else {
1042 state.push_unknown();
1043 }
1044 state.push_unknown(); }
1046
1047 "drop" => {
1049 let dropped = state.pop();
1050 if let Some(StackValue::Resource(r)) = dropped {
1053 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1054 if !already_consumed {
1055 self.emit_drop_warning(&r, span, word);
1056 }
1057 }
1058 }
1059
1060 "dup" => {
1061 if let Some(top) = state.peek().cloned() {
1062 state.stack.push(top);
1063 } else {
1064 state.push_unknown();
1065 }
1066 }
1067
1068 "swap" => {
1069 let a = state.pop();
1070 let b = state.pop();
1071 if let Some(av) = a {
1072 state.stack.push(av);
1073 }
1074 if let Some(bv) = b {
1075 state.stack.push(bv);
1076 }
1077 }
1078
1079 "over" => {
1080 if state.depth() >= 2 {
1082 let second = state.stack[state.depth() - 2].clone();
1083 state.stack.push(second);
1084 } else {
1085 state.push_unknown();
1086 }
1087 }
1088
1089 "rot" => {
1090 let c = state.pop();
1092 let b = state.pop();
1093 let a = state.pop();
1094 if let Some(bv) = b {
1095 state.stack.push(bv);
1096 }
1097 if let Some(cv) = c {
1098 state.stack.push(cv);
1099 }
1100 if let Some(av) = a {
1101 state.stack.push(av);
1102 }
1103 }
1104
1105 "nip" => {
1106 let b = state.pop();
1108 let a = state.pop();
1109 if let Some(StackValue::Resource(r)) = a {
1110 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1111 if !already_consumed {
1112 self.emit_drop_warning(&r, span, word);
1113 }
1114 }
1115 if let Some(bv) = b {
1116 state.stack.push(bv);
1117 }
1118 }
1119
1120 ">aux" => {
1121 if let Some(val) = state.pop() {
1123 state.aux_stack.push(val);
1124 }
1125 }
1126
1127 "aux>" => {
1128 if let Some(val) = state.aux_stack.pop() {
1130 state.stack.push(val);
1131 }
1132 }
1133
1134 "tuck" => {
1135 let b = state.pop();
1137 let a = state.pop();
1138 if let Some(bv) = b.clone() {
1139 state.stack.push(bv);
1140 }
1141 if let Some(av) = a {
1142 state.stack.push(av);
1143 }
1144 if let Some(bv) = b {
1145 state.stack.push(bv);
1146 }
1147 }
1148
1149 "2dup" => {
1150 if state.depth() >= 2 {
1152 let b = state.stack[state.depth() - 1].clone();
1153 let a = state.stack[state.depth() - 2].clone();
1154 state.stack.push(a);
1155 state.stack.push(b);
1156 } else {
1157 state.push_unknown();
1158 state.push_unknown();
1159 }
1160 }
1161
1162 "3drop" => {
1163 for _ in 0..3 {
1164 if let Some(StackValue::Resource(r)) = state.pop() {
1165 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1166 if !already_consumed {
1167 self.emit_drop_warning(&r, span, word);
1168 }
1169 }
1170 }
1171 }
1172
1173 "pick" => {
1174 state.pop(); state.push_unknown();
1178 }
1179
1180 "roll" => {
1181 state.pop(); state.push_unknown();
1184 }
1185
1186 "chan.send" | "chan.receive" => {
1188 state.pop();
1192 state.pop();
1193 state.push_unknown();
1194 state.push_unknown();
1195 }
1196
1197 "strand.spawn" => {
1200 state.pop(); let resources_on_stack: Vec<TrackedResource> = state
1205 .stack
1206 .iter()
1207 .filter_map(|v| match v {
1208 StackValue::Resource(r) => Some(r.clone()),
1209 StackValue::Unknown => None,
1210 })
1211 .collect();
1212 for r in resources_on_stack {
1213 state.consume_resource(r);
1214 }
1215 state.push_unknown(); }
1217
1218 _ => {
1222 }
1226 }
1227 }
1228
1229 fn analyze_if(
1231 &mut self,
1232 then_branch: &[Statement],
1233 else_branch: Option<&Vec<Statement>>,
1234 state: &mut StackState,
1235 word: &WordDef,
1236 ) {
1237 state.pop();
1239
1240 let mut then_state = state.clone();
1242 let mut else_state = state.clone();
1243
1244 self.analyze_statements(then_branch, &mut then_state, word);
1246
1247 if let Some(else_stmts) = else_branch {
1249 self.analyze_statements(else_stmts, &mut else_state, word);
1250 }
1251
1252 let merge_result = then_state.merge(&else_state);
1254 for inconsistent in merge_result.inconsistent {
1255 self.emit_branch_inconsistency_warning(&inconsistent, word);
1256 }
1257
1258 *state = then_state.join(&else_state);
1262 }
1263
1264 fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
1266 state.pop();
1268
1269 if arms.is_empty() {
1270 return;
1271 }
1272
1273 let mut arm_states: Vec<StackState> = Vec::new();
1275
1276 for arm in arms {
1277 let mut arm_state = state.clone();
1278
1279 match &arm.pattern {
1282 crate::ast::Pattern::Variant(_) => {
1283 }
1286 crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
1287 for _ in bindings {
1289 arm_state.push_unknown();
1290 }
1291 }
1292 }
1293
1294 self.analyze_statements(&arm.body, &mut arm_state, word);
1295 arm_states.push(arm_state);
1296 }
1297
1298 if arm_states.len() >= 2 {
1300 let first = &arm_states[0];
1301 for other in &arm_states[1..] {
1302 let merge_result = first.merge(other);
1303 for inconsistent in merge_result.inconsistent {
1304 self.emit_branch_inconsistency_warning(&inconsistent, word);
1305 }
1306 }
1307 }
1308
1309 if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
1312 *state = first;
1313 }
1314 }
1315
1316 fn emit_drop_warning(
1318 &mut self,
1319 resource: &TrackedResource,
1320 span: Option<&Span>,
1321 word: &WordDef,
1322 ) {
1323 let line = span
1324 .map(|s| s.line)
1325 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
1326 let column = span.map(|s| s.column);
1327
1328 self.diagnostics.push(LintDiagnostic {
1329 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
1330 message: format!(
1331 "{} created at line {} dropped without cleanup - {}",
1332 resource.kind.name(),
1333 resource.created_line + 1,
1334 resource.kind.cleanup_suggestion()
1335 ),
1336 severity: Severity::Warning,
1337 replacement: String::new(),
1338 file: self.file.clone(),
1339 line,
1340 end_line: None,
1341 start_column: column,
1342 end_column: column.map(|c| c + 4), word_name: word.name.clone(),
1344 start_index: 0,
1345 end_index: 0,
1346 });
1347 }
1348
1349 fn emit_branch_inconsistency_warning(
1351 &mut self,
1352 inconsistent: &InconsistentResource,
1353 word: &WordDef,
1354 ) {
1355 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
1356 let branch = if inconsistent.consumed_in_else {
1357 "else"
1358 } else {
1359 "then"
1360 };
1361
1362 self.diagnostics.push(LintDiagnostic {
1363 id: "resource-branch-inconsistent".to_string(),
1364 message: format!(
1365 "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
1366 inconsistent.resource.kind.name(),
1367 inconsistent.resource.created_line + 1,
1368 branch
1369 ),
1370 severity: Severity::Warning,
1371 replacement: String::new(),
1372 file: self.file.clone(),
1373 line,
1374 end_line: None,
1375 start_column: None,
1376 end_column: None,
1377 word_name: word.name.clone(),
1378 start_index: 0,
1379 end_index: 0,
1380 });
1381 }
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386 use super::*;
1387 use crate::ast::{Statement, WordDef};
1388
1389 fn make_word_call(name: &str) -> Statement {
1390 Statement::WordCall {
1391 name: name.to_string(),
1392 span: Some(Span::new(0, 0, name.len())),
1393 }
1394 }
1395
1396 #[test]
1397 fn test_immediate_weave_drop() {
1398 let word = WordDef {
1400 name: "bad".to_string(),
1401 effect: None,
1402 body: vec![
1403 Statement::Quotation {
1404 span: None,
1405 id: 0,
1406 body: vec![make_word_call("gen")],
1407 },
1408 make_word_call("strand.weave"),
1409 make_word_call("drop"),
1410 ],
1411 source: None,
1412 allowed_lints: vec![],
1413 };
1414
1415 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1416 let diagnostics = analyzer.analyze_word(&word);
1417
1418 assert_eq!(diagnostics.len(), 1);
1419 assert!(diagnostics[0].id.contains("weavehandle"));
1420 assert!(diagnostics[0].message.contains("dropped without cleanup"));
1421 }
1422
1423 #[test]
1424 fn test_weave_properly_cancelled() {
1425 let word = WordDef {
1427 name: "good".to_string(),
1428 effect: None,
1429 body: vec![
1430 Statement::Quotation {
1431 span: None,
1432 id: 0,
1433 body: vec![make_word_call("gen")],
1434 },
1435 make_word_call("strand.weave"),
1436 make_word_call("strand.weave-cancel"),
1437 ],
1438 source: None,
1439 allowed_lints: vec![],
1440 };
1441
1442 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1443 let diagnostics = analyzer.analyze_word(&word);
1444
1445 assert!(
1446 diagnostics.is_empty(),
1447 "Expected no warnings for properly cancelled weave"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_branch_inconsistent_handling() {
1453 let word = WordDef {
1457 name: "bad".to_string(),
1458 effect: None,
1459 body: vec![
1460 Statement::Quotation {
1461 span: None,
1462 id: 0,
1463 body: vec![make_word_call("gen")],
1464 },
1465 make_word_call("strand.weave"),
1466 Statement::BoolLiteral(true),
1467 Statement::If {
1468 then_branch: vec![make_word_call("strand.weave-cancel")],
1469 else_branch: Some(vec![make_word_call("drop")]),
1470 span: None,
1471 },
1472 ],
1473 source: None,
1474 allowed_lints: vec![],
1475 };
1476
1477 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1478 let diagnostics = analyzer.analyze_word(&word);
1479
1480 assert!(!diagnostics.is_empty());
1482 }
1483
1484 #[test]
1485 fn test_both_branches_cancel() {
1486 let word = WordDef {
1490 name: "good".to_string(),
1491 effect: None,
1492 body: vec![
1493 Statement::Quotation {
1494 span: None,
1495 id: 0,
1496 body: vec![make_word_call("gen")],
1497 },
1498 make_word_call("strand.weave"),
1499 Statement::BoolLiteral(true),
1500 Statement::If {
1501 then_branch: vec![make_word_call("strand.weave-cancel")],
1502 else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
1503 span: None,
1504 },
1505 ],
1506 source: None,
1507 allowed_lints: vec![],
1508 };
1509
1510 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1511 let diagnostics = analyzer.analyze_word(&word);
1512
1513 assert!(
1514 diagnostics.is_empty(),
1515 "Expected no warnings when both branches cancel"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_channel_leak() {
1521 let word = WordDef {
1523 name: "bad".to_string(),
1524 effect: None,
1525 body: vec![make_word_call("chan.make"), make_word_call("drop")],
1526 source: None,
1527 allowed_lints: vec![],
1528 };
1529
1530 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1531 let diagnostics = analyzer.analyze_word(&word);
1532
1533 assert_eq!(diagnostics.len(), 1);
1534 assert!(diagnostics[0].id.contains("channel"));
1535 }
1536
1537 #[test]
1538 fn test_channel_properly_closed() {
1539 let word = WordDef {
1541 name: "good".to_string(),
1542 effect: None,
1543 body: vec![make_word_call("chan.make"), make_word_call("chan.close")],
1544 source: None,
1545 allowed_lints: vec![],
1546 };
1547
1548 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1549 let diagnostics = analyzer.analyze_word(&word);
1550
1551 assert!(
1552 diagnostics.is_empty(),
1553 "Expected no warnings for properly closed channel"
1554 );
1555 }
1556
1557 #[test]
1558 fn test_swap_resource_tracking() {
1559 let word = WordDef {
1563 name: "test".to_string(),
1564 effect: None,
1565 body: vec![
1566 make_word_call("chan.make"),
1567 Statement::IntLiteral(1),
1568 make_word_call("swap"),
1569 make_word_call("drop"), make_word_call("drop"), ],
1572 source: None,
1573 allowed_lints: vec![],
1574 };
1575
1576 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1577 let diagnostics = analyzer.analyze_word(&word);
1578
1579 assert_eq!(
1580 diagnostics.len(),
1581 1,
1582 "Expected warning for dropped channel: {:?}",
1583 diagnostics
1584 );
1585 assert!(diagnostics[0].id.contains("channel"));
1586 }
1587
1588 #[test]
1589 fn test_over_resource_tracking() {
1590 let word = WordDef {
1596 name: "test".to_string(),
1597 effect: None,
1598 body: vec![
1599 make_word_call("chan.make"),
1600 Statement::IntLiteral(1),
1601 make_word_call("over"),
1602 make_word_call("drop"), make_word_call("drop"), make_word_call("drop"), ],
1606 source: None,
1607 allowed_lints: vec![],
1608 };
1609
1610 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1611 let diagnostics = analyzer.analyze_word(&word);
1612
1613 assert_eq!(
1615 diagnostics.len(),
1616 2,
1617 "Expected 2 warnings for dropped channels: {:?}",
1618 diagnostics
1619 );
1620 }
1621
1622 #[test]
1623 fn test_channel_transferred_via_spawn() {
1624 let word = WordDef {
1632 name: "accept-loop".to_string(),
1633 effect: None,
1634 body: vec![
1635 make_word_call("chan.make"),
1636 make_word_call("dup"),
1637 Statement::Quotation {
1638 span: None,
1639 id: 0,
1640 body: vec![make_word_call("worker")],
1641 },
1642 make_word_call("strand.spawn"),
1643 make_word_call("drop"),
1644 make_word_call("drop"),
1645 make_word_call("chan.send"),
1646 ],
1647 source: None,
1648 allowed_lints: vec![],
1649 };
1650
1651 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1652 let diagnostics = analyzer.analyze_word(&word);
1653
1654 assert!(
1655 diagnostics.is_empty(),
1656 "Expected no warnings when channel is transferred via strand.spawn: {:?}",
1657 diagnostics
1658 );
1659 }
1660
1661 #[test]
1662 fn test_else_branch_only_leak() {
1663 let word = WordDef {
1669 name: "test".to_string(),
1670 effect: None,
1671 body: vec![
1672 make_word_call("chan.make"),
1673 Statement::BoolLiteral(true),
1674 Statement::If {
1675 then_branch: vec![make_word_call("chan.close")],
1676 else_branch: Some(vec![make_word_call("drop")]),
1677 span: None,
1678 },
1679 ],
1680 source: None,
1681 allowed_lints: vec![],
1682 };
1683
1684 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1685 let diagnostics = analyzer.analyze_word(&word);
1686
1687 assert!(
1689 !diagnostics.is_empty(),
1690 "Expected warnings for else-branch leak: {:?}",
1691 diagnostics
1692 );
1693 }
1694
1695 #[test]
1696 fn test_branch_join_both_consume() {
1697 let word = WordDef {
1702 name: "test".to_string(),
1703 effect: None,
1704 body: vec![
1705 make_word_call("chan.make"),
1706 Statement::BoolLiteral(true),
1707 Statement::If {
1708 then_branch: vec![make_word_call("chan.close")],
1709 else_branch: Some(vec![make_word_call("chan.close")]),
1710 span: None,
1711 },
1712 ],
1713 source: None,
1714 allowed_lints: vec![],
1715 };
1716
1717 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1718 let diagnostics = analyzer.analyze_word(&word);
1719
1720 assert!(
1721 diagnostics.is_empty(),
1722 "Expected no warnings when both branches consume: {:?}",
1723 diagnostics
1724 );
1725 }
1726
1727 #[test]
1728 fn test_branch_join_neither_consume() {
1729 let word = WordDef {
1734 name: "test".to_string(),
1735 effect: None,
1736 body: vec![
1737 make_word_call("chan.make"),
1738 Statement::BoolLiteral(true),
1739 Statement::If {
1740 then_branch: vec![],
1741 else_branch: Some(vec![]),
1742 span: None,
1743 },
1744 make_word_call("drop"), ],
1746 source: None,
1747 allowed_lints: vec![],
1748 };
1749
1750 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1751 let diagnostics = analyzer.analyze_word(&word);
1752
1753 assert_eq!(
1754 diagnostics.len(),
1755 1,
1756 "Expected warning for dropped channel: {:?}",
1757 diagnostics
1758 );
1759 assert!(diagnostics[0].id.contains("channel"));
1760 }
1761
1762 #[test]
1767 fn test_cross_word_resource_tracking() {
1768 use crate::ast::Program;
1775
1776 let make_chan = WordDef {
1777 name: "make-chan".to_string(),
1778 effect: None,
1779 body: vec![make_word_call("chan.make")],
1780 source: None,
1781 allowed_lints: vec![],
1782 };
1783
1784 let leak_it = WordDef {
1785 name: "leak-it".to_string(),
1786 effect: None,
1787 body: vec![make_word_call("make-chan"), make_word_call("drop")],
1788 source: None,
1789 allowed_lints: vec![],
1790 };
1791
1792 let program = Program {
1793 words: vec![make_chan, leak_it],
1794 includes: vec![],
1795 unions: vec![],
1796 };
1797
1798 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1799 let diagnostics = analyzer.analyze_program(&program);
1800
1801 assert_eq!(
1802 diagnostics.len(),
1803 1,
1804 "Expected warning for dropped channel from make-chan: {:?}",
1805 diagnostics
1806 );
1807 assert!(diagnostics[0].id.contains("channel"));
1808 assert!(diagnostics[0].message.contains("make-chan"));
1809 }
1810
1811 #[test]
1812 fn test_cross_word_proper_cleanup() {
1813 use crate::ast::Program;
1818
1819 let make_chan = WordDef {
1820 name: "make-chan".to_string(),
1821 effect: None,
1822 body: vec![make_word_call("chan.make")],
1823 source: None,
1824 allowed_lints: vec![],
1825 };
1826
1827 let use_it = WordDef {
1828 name: "use-it".to_string(),
1829 effect: None,
1830 body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1831 source: None,
1832 allowed_lints: vec![],
1833 };
1834
1835 let program = Program {
1836 words: vec![make_chan, use_it],
1837 includes: vec![],
1838 unions: vec![],
1839 };
1840
1841 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1842 let diagnostics = analyzer.analyze_program(&program);
1843
1844 assert!(
1845 diagnostics.is_empty(),
1846 "Expected no warnings for properly closed channel: {:?}",
1847 diagnostics
1848 );
1849 }
1850
1851 #[test]
1852 fn test_cross_word_chain() {
1853 use crate::ast::Program;
1859
1860 let make_chan = WordDef {
1861 name: "make-chan".to_string(),
1862 effect: None,
1863 body: vec![make_word_call("chan.make")],
1864 source: None,
1865 allowed_lints: vec![],
1866 };
1867
1868 let wrap_chan = WordDef {
1869 name: "wrap-chan".to_string(),
1870 effect: None,
1871 body: vec![make_word_call("make-chan")],
1872 source: None,
1873 allowed_lints: vec![],
1874 };
1875
1876 let leak_chain = WordDef {
1877 name: "leak-chain".to_string(),
1878 effect: None,
1879 body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1880 source: None,
1881 allowed_lints: vec![],
1882 };
1883
1884 let program = Program {
1885 words: vec![make_chan, wrap_chan, leak_chain],
1886 includes: vec![],
1887 unions: vec![],
1888 };
1889
1890 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1891 let diagnostics = analyzer.analyze_program(&program);
1892
1893 assert_eq!(
1895 diagnostics.len(),
1896 1,
1897 "Expected warning for dropped channel through chain: {:?}",
1898 diagnostics
1899 );
1900 }
1901}