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 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 struct TrackedResource {
69 pub kind: ResourceKind,
71 pub id: usize,
73 pub created_line: usize,
75 pub created_column: usize,
77 pub created_by: String,
79}
80
81#[derive(Debug, Clone)]
83pub enum StackValue {
84 Resource(TrackedResource),
86 Unknown,
88}
89
90#[derive(Debug, Clone)]
92pub struct StackState {
93 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 consumed: Vec::new(),
112 next_id: 0,
113 }
114 }
115
116 pub fn push_unknown(&mut self) {
118 self.stack.push(StackValue::Unknown);
119 }
120
121 pub fn push_resource(&mut self, kind: ResourceKind, line: usize, column: usize, word: &str) {
123 let resource = TrackedResource {
124 kind,
125 id: self.next_id,
126 created_line: line,
127 created_column: column,
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 StackState {
286 stack: joined_stack,
287 consumed: definitely_consumed,
288 next_id: self.next_id.max(other.next_id),
289 }
290 }
291}
292
293#[derive(Debug)]
295pub struct BranchMergeResult {
296 pub inconsistent: Vec<InconsistentResource>,
298}
299
300#[derive(Debug)]
302pub struct InconsistentResource {
303 pub resource: TrackedResource,
304 pub consumed_in_else: bool,
306}
307
308#[derive(Debug, Clone, Default)]
314pub struct WordResourceInfo {
315 pub returns: Vec<ResourceKind>,
317}
318
319pub struct ProgramResourceAnalyzer {
325 word_info: HashMap<String, WordResourceInfo>,
327 file: std::path::PathBuf,
329 diagnostics: Vec<LintDiagnostic>,
331}
332
333impl ProgramResourceAnalyzer {
334 pub fn new(file: &Path) -> Self {
335 ProgramResourceAnalyzer {
336 word_info: HashMap::new(),
337 file: file.to_path_buf(),
338 diagnostics: Vec::new(),
339 }
340 }
341
342 pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
344 self.diagnostics.clear();
345 self.word_info.clear();
346
347 for word in &program.words {
349 let info = self.collect_word_info(word);
350 self.word_info.insert(word.name.clone(), info);
351 }
352
353 for word in &program.words {
355 self.analyze_word_with_context(word);
356 }
357
358 std::mem::take(&mut self.diagnostics)
359 }
360
361 fn collect_word_info(&self, word: &WordDef) -> WordResourceInfo {
363 let mut state = StackState::new();
364
365 self.simulate_statements(&word.body, &mut state);
367
368 let returns: Vec<ResourceKind> = state
370 .remaining_resources()
371 .into_iter()
372 .map(|r| r.kind)
373 .collect();
374
375 WordResourceInfo { returns }
376 }
377
378 fn simulate_statements(&self, statements: &[Statement], state: &mut StackState) {
380 for stmt in statements {
381 self.simulate_statement(stmt, state);
382 }
383 }
384
385 fn simulate_statement(&self, stmt: &Statement, state: &mut StackState) {
387 match stmt {
388 Statement::IntLiteral(_)
389 | Statement::FloatLiteral(_)
390 | Statement::BoolLiteral(_)
391 | Statement::StringLiteral(_)
392 | Statement::Symbol(_) => {
393 state.push_unknown();
394 }
395
396 Statement::WordCall { name, span } => {
397 self.simulate_word_call(name, span.as_ref(), state);
398 }
399
400 Statement::Quotation { .. } => {
401 state.push_unknown();
402 }
403
404 Statement::If {
405 then_branch,
406 else_branch,
407 } => {
408 state.pop(); let mut then_state = state.clone();
410 let mut else_state = state.clone();
411 self.simulate_statements(then_branch, &mut then_state);
412 if let Some(else_stmts) = else_branch {
413 self.simulate_statements(else_stmts, &mut else_state);
414 }
415 *state = then_state.join(&else_state);
416 }
417
418 Statement::Match { arms } => {
419 state.pop();
420 let mut arm_states: Vec<StackState> = Vec::new();
421 for arm in arms {
422 let mut arm_state = state.clone();
423 self.simulate_statements(&arm.body, &mut arm_state);
424 arm_states.push(arm_state);
425 }
426 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
427 *state = joined;
428 }
429 }
430 }
431 }
432
433 fn simulate_word_common<F>(
441 name: &str,
442 span: Option<&Span>,
443 state: &mut StackState,
444 word_info: &HashMap<String, WordResourceInfo>,
445 mut on_resource_dropped: F,
446 ) -> bool
447 where
448 F: FnMut(&TrackedResource),
449 {
450 let line = span.map(|s| s.line).unwrap_or(0);
451 let column = span.map(|s| s.column).unwrap_or(0);
452
453 match name {
454 "strand.weave" => {
456 state.pop();
457 state.push_resource(ResourceKind::WeaveHandle, line, column, name);
458 }
459 "chan.make" => {
460 state.push_resource(ResourceKind::Channel, line, column, name);
461 }
462
463 "strand.weave-cancel" => {
465 if let Some(StackValue::Resource(r)) = state.pop()
466 && r.kind == ResourceKind::WeaveHandle
467 {
468 state.consume_resource(r);
469 }
470 }
471 "chan.close" => {
472 if let Some(StackValue::Resource(r)) = state.pop()
473 && r.kind == ResourceKind::Channel
474 {
475 state.consume_resource(r);
476 }
477 }
478
479 "drop" => {
481 let dropped = state.pop();
482 if let Some(StackValue::Resource(r)) = dropped {
483 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
485 if !already_consumed {
486 on_resource_dropped(&r);
487 }
488 }
489 }
490 "dup" => {
491 if let Some(top) = state.peek().cloned() {
494 state.stack.push(top);
495 }
496 }
497 "swap" => {
498 let a = state.pop();
499 let b = state.pop();
500 if let Some(av) = a {
501 state.stack.push(av);
502 }
503 if let Some(bv) = b {
504 state.stack.push(bv);
505 }
506 }
507 "over" => {
508 if state.depth() >= 2 {
510 let second = state.stack[state.depth() - 2].clone();
511 state.stack.push(second);
512 }
513 }
514 "rot" => {
515 let c = state.pop();
517 let b = state.pop();
518 let a = state.pop();
519 if let Some(bv) = b {
520 state.stack.push(bv);
521 }
522 if let Some(cv) = c {
523 state.stack.push(cv);
524 }
525 if let Some(av) = a {
526 state.stack.push(av);
527 }
528 }
529 "nip" => {
530 let b = state.pop();
532 let a = state.pop();
533 if let Some(StackValue::Resource(r)) = a {
534 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
535 if !already_consumed {
536 on_resource_dropped(&r);
537 }
538 }
539 if let Some(bv) = b {
540 state.stack.push(bv);
541 }
542 }
543 "tuck" => {
544 let b = state.pop();
546 let a = state.pop();
547 if let Some(bv) = b.clone() {
548 state.stack.push(bv);
549 }
550 if let Some(av) = a {
551 state.stack.push(av);
552 }
553 if let Some(bv) = b {
554 state.stack.push(bv);
555 }
556 }
557
558 "strand.spawn" => {
560 state.pop();
561 let resources: Vec<TrackedResource> = state
562 .stack
563 .iter()
564 .filter_map(|v| match v {
565 StackValue::Resource(r) => Some(r.clone()),
566 StackValue::Unknown => None,
567 })
568 .collect();
569 for r in resources {
570 state.consume_resource(r);
571 }
572 state.push_unknown();
573 }
574
575 "map.set" => {
577 let value = state.pop();
579 state.pop(); state.pop(); if let Some(StackValue::Resource(r)) = value {
583 state.consume_resource(r);
584 }
585 state.push_unknown(); }
587
588 "list.push" | "list.prepend" => {
590 let value = state.pop();
592 state.pop(); if let Some(StackValue::Resource(r)) = value {
594 state.consume_resource(r);
595 }
596 state.push_unknown(); }
598
599 _ => {
601 if let Some(info) = word_info.get(name) {
602 for kind in &info.returns {
604 state.push_resource(*kind, line, column, name);
605 }
606 return true;
607 }
608 return false;
610 }
611 }
612 true
613 }
614
615 fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
617 Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
619 }
620
621 fn analyze_word_with_context(&mut self, word: &WordDef) {
623 let mut state = StackState::new();
624
625 self.analyze_statements_with_context(&word.body, &mut state, word);
626
627 }
629
630 fn analyze_statements_with_context(
632 &mut self,
633 statements: &[Statement],
634 state: &mut StackState,
635 word: &WordDef,
636 ) {
637 for stmt in statements {
638 self.analyze_statement_with_context(stmt, state, word);
639 }
640 }
641
642 fn analyze_statement_with_context(
644 &mut self,
645 stmt: &Statement,
646 state: &mut StackState,
647 word: &WordDef,
648 ) {
649 match stmt {
650 Statement::IntLiteral(_)
651 | Statement::FloatLiteral(_)
652 | Statement::BoolLiteral(_)
653 | Statement::StringLiteral(_)
654 | Statement::Symbol(_) => {
655 state.push_unknown();
656 }
657
658 Statement::WordCall { name, span } => {
659 self.analyze_word_call_with_context(name, span.as_ref(), state, word);
660 }
661
662 Statement::Quotation { .. } => {
663 state.push_unknown();
664 }
665
666 Statement::If {
667 then_branch,
668 else_branch,
669 } => {
670 state.pop();
671 let mut then_state = state.clone();
672 let mut else_state = state.clone();
673
674 self.analyze_statements_with_context(then_branch, &mut then_state, word);
675 if let Some(else_stmts) = else_branch {
676 self.analyze_statements_with_context(else_stmts, &mut else_state, word);
677 }
678
679 let merge_result = then_state.merge(&else_state);
681 for inconsistent in merge_result.inconsistent {
682 self.emit_branch_inconsistency_warning(&inconsistent, word);
683 }
684
685 *state = then_state.join(&else_state);
686 }
687
688 Statement::Match { arms } => {
689 state.pop();
690 let mut arm_states: Vec<StackState> = Vec::new();
691
692 for arm in arms {
693 let mut arm_state = state.clone();
694 self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
695 arm_states.push(arm_state);
696 }
697
698 if arm_states.len() >= 2 {
700 let first = &arm_states[0];
701 for other in &arm_states[1..] {
702 let merge_result = first.merge(other);
703 for inconsistent in merge_result.inconsistent {
704 self.emit_branch_inconsistency_warning(&inconsistent, word);
705 }
706 }
707 }
708
709 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
710 *state = joined;
711 }
712 }
713 }
714 }
715
716 fn analyze_word_call_with_context(
718 &mut self,
719 name: &str,
720 span: Option<&Span>,
721 state: &mut StackState,
722 word: &WordDef,
723 ) {
724 let mut dropped_resources: Vec<TrackedResource> = Vec::new();
726
727 let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
729 dropped_resources.push(r.clone())
730 });
731
732 for r in dropped_resources {
734 self.emit_drop_warning(&r, span, word);
735 }
736
737 if handled {
738 return;
739 }
740
741 match name {
743 "strand.resume" => {
745 let value = state.pop();
746 let handle = state.pop();
747 if let Some(h) = handle {
748 state.stack.push(h);
749 } else {
750 state.push_unknown();
751 }
752 if let Some(v) = value {
753 state.stack.push(v);
754 } else {
755 state.push_unknown();
756 }
757 state.push_unknown();
758 }
759
760 "2dup" => {
761 if state.depth() >= 2 {
762 let b = state.stack[state.depth() - 1].clone();
763 let a = state.stack[state.depth() - 2].clone();
764 state.stack.push(a);
765 state.stack.push(b);
766 } else {
767 state.push_unknown();
768 state.push_unknown();
769 }
770 }
771
772 "3drop" => {
773 for _ in 0..3 {
774 if let Some(StackValue::Resource(r)) = state.pop() {
775 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
776 if !already_consumed {
777 self.emit_drop_warning(&r, span, word);
778 }
779 }
780 }
781 }
782
783 "pick" | "roll" => {
784 state.pop();
785 state.push_unknown();
786 }
787
788 "chan.send" | "chan.receive" => {
789 state.pop();
790 state.pop();
791 state.push_unknown();
792 state.push_unknown();
793 }
794
795 _ => {}
797 }
798 }
799
800 fn emit_drop_warning(
801 &mut self,
802 resource: &TrackedResource,
803 span: Option<&Span>,
804 word: &WordDef,
805 ) {
806 let line = span
807 .map(|s| s.line)
808 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
809 let column = span.map(|s| s.column);
810
811 self.diagnostics.push(LintDiagnostic {
812 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
813 message: format!(
814 "{} from `{}` (line {}) dropped without cleanup - {}",
815 resource.kind.name(),
816 resource.created_by,
817 resource.created_line + 1,
818 resource.kind.cleanup_suggestion()
819 ),
820 severity: Severity::Warning,
821 replacement: String::new(),
822 file: self.file.clone(),
823 line,
824 end_line: None,
825 start_column: column,
826 end_column: column.map(|c| c + 4),
827 word_name: word.name.clone(),
828 start_index: 0,
829 end_index: 0,
830 });
831 }
832
833 fn emit_branch_inconsistency_warning(
834 &mut self,
835 inconsistent: &InconsistentResource,
836 word: &WordDef,
837 ) {
838 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
839 let branch = if inconsistent.consumed_in_else {
840 "else"
841 } else {
842 "then"
843 };
844
845 self.diagnostics.push(LintDiagnostic {
846 id: "resource-branch-inconsistent".to_string(),
847 message: format!(
848 "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
849 inconsistent.resource.kind.name(),
850 inconsistent.resource.created_by,
851 inconsistent.resource.created_line + 1,
852 branch
853 ),
854 severity: Severity::Warning,
855 replacement: String::new(),
856 file: self.file.clone(),
857 line,
858 end_line: None,
859 start_column: None,
860 end_column: None,
861 word_name: word.name.clone(),
862 start_index: 0,
863 end_index: 0,
864 });
865 }
866}
867
868pub struct ResourceAnalyzer {
870 diagnostics: Vec<LintDiagnostic>,
872 file: std::path::PathBuf,
874}
875
876impl ResourceAnalyzer {
877 pub fn new(file: &Path) -> Self {
878 ResourceAnalyzer {
879 diagnostics: Vec::new(),
880 file: file.to_path_buf(),
881 }
882 }
883
884 pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
886 self.diagnostics.clear();
887
888 let mut state = StackState::new();
889
890 self.analyze_statements(&word.body, &mut state, word);
892
893 let _ = state.remaining_resources(); std::mem::take(&mut self.diagnostics)
909 }
910
911 fn analyze_statements(
913 &mut self,
914 statements: &[Statement],
915 state: &mut StackState,
916 word: &WordDef,
917 ) {
918 for stmt in statements {
919 self.analyze_statement(stmt, state, word);
920 }
921 }
922
923 fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
925 match stmt {
926 Statement::IntLiteral(_)
927 | Statement::FloatLiteral(_)
928 | Statement::BoolLiteral(_)
929 | Statement::StringLiteral(_)
930 | Statement::Symbol(_) => {
931 state.push_unknown();
932 }
933
934 Statement::WordCall { name, span } => {
935 self.analyze_word_call(name, span.as_ref(), state, word);
936 }
937
938 Statement::Quotation { body, .. } => {
939 let _ = body; state.push_unknown();
945 }
946
947 Statement::If {
948 then_branch,
949 else_branch,
950 } => {
951 self.analyze_if(then_branch, else_branch.as_ref(), state, word);
952 }
953
954 Statement::Match { arms } => {
955 self.analyze_match(arms, state, word);
956 }
957 }
958 }
959
960 fn analyze_word_call(
962 &mut self,
963 name: &str,
964 span: Option<&Span>,
965 state: &mut StackState,
966 word: &WordDef,
967 ) {
968 let line = span.map(|s| s.line).unwrap_or(0);
969 let column = span.map(|s| s.column).unwrap_or(0);
970
971 match name {
972 "strand.weave" => {
974 state.pop(); state.push_resource(ResourceKind::WeaveHandle, line, column, name);
977 }
978
979 "chan.make" => {
980 state.push_resource(ResourceKind::Channel, line, column, name);
982 }
983
984 "strand.weave-cancel" => {
986 if let Some(StackValue::Resource(r)) = state.pop()
988 && r.kind == ResourceKind::WeaveHandle
989 {
990 state.consume_resource(r);
991 }
992 }
993
994 "chan.close" => {
995 if let Some(StackValue::Resource(r)) = state.pop()
997 && r.kind == ResourceKind::Channel
998 {
999 state.consume_resource(r);
1000 }
1001 }
1002
1003 "strand.resume" => {
1008 let value = state.pop(); let handle = state.pop(); if let Some(h) = handle {
1014 state.stack.push(h);
1015 } else {
1016 state.push_unknown();
1017 }
1018 if let Some(v) = value {
1019 state.stack.push(v);
1020 } else {
1021 state.push_unknown();
1022 }
1023 state.push_unknown(); }
1025
1026 "drop" => {
1028 let dropped = state.pop();
1029 if let Some(StackValue::Resource(r)) = dropped {
1032 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1033 if !already_consumed {
1034 self.emit_drop_warning(&r, span, word);
1035 }
1036 }
1037 }
1038
1039 "dup" => {
1040 if let Some(top) = state.peek().cloned() {
1041 state.stack.push(top);
1042 } else {
1043 state.push_unknown();
1044 }
1045 }
1046
1047 "swap" => {
1048 let a = state.pop();
1049 let b = state.pop();
1050 if let Some(av) = a {
1051 state.stack.push(av);
1052 }
1053 if let Some(bv) = b {
1054 state.stack.push(bv);
1055 }
1056 }
1057
1058 "over" => {
1059 if state.depth() >= 2 {
1061 let second = state.stack[state.depth() - 2].clone();
1062 state.stack.push(second);
1063 } else {
1064 state.push_unknown();
1065 }
1066 }
1067
1068 "rot" => {
1069 let c = state.pop();
1071 let b = state.pop();
1072 let a = state.pop();
1073 if let Some(bv) = b {
1074 state.stack.push(bv);
1075 }
1076 if let Some(cv) = c {
1077 state.stack.push(cv);
1078 }
1079 if let Some(av) = a {
1080 state.stack.push(av);
1081 }
1082 }
1083
1084 "nip" => {
1085 let b = state.pop();
1087 let a = state.pop();
1088 if let Some(StackValue::Resource(r)) = a {
1089 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1090 if !already_consumed {
1091 self.emit_drop_warning(&r, span, word);
1092 }
1093 }
1094 if let Some(bv) = b {
1095 state.stack.push(bv);
1096 }
1097 }
1098
1099 "tuck" => {
1100 let b = state.pop();
1102 let a = state.pop();
1103 if let Some(bv) = b.clone() {
1104 state.stack.push(bv);
1105 }
1106 if let Some(av) = a {
1107 state.stack.push(av);
1108 }
1109 if let Some(bv) = b {
1110 state.stack.push(bv);
1111 }
1112 }
1113
1114 "2dup" => {
1115 if state.depth() >= 2 {
1117 let b = state.stack[state.depth() - 1].clone();
1118 let a = state.stack[state.depth() - 2].clone();
1119 state.stack.push(a);
1120 state.stack.push(b);
1121 } else {
1122 state.push_unknown();
1123 state.push_unknown();
1124 }
1125 }
1126
1127 "3drop" => {
1128 for _ in 0..3 {
1129 if let Some(StackValue::Resource(r)) = state.pop() {
1130 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1131 if !already_consumed {
1132 self.emit_drop_warning(&r, span, word);
1133 }
1134 }
1135 }
1136 }
1137
1138 "pick" => {
1139 state.pop(); state.push_unknown();
1143 }
1144
1145 "roll" => {
1146 state.pop(); state.push_unknown();
1149 }
1150
1151 "chan.send" | "chan.receive" => {
1153 state.pop();
1157 state.pop();
1158 state.push_unknown();
1159 state.push_unknown();
1160 }
1161
1162 "strand.spawn" => {
1165 state.pop(); let resources_on_stack: Vec<TrackedResource> = state
1170 .stack
1171 .iter()
1172 .filter_map(|v| match v {
1173 StackValue::Resource(r) => Some(r.clone()),
1174 StackValue::Unknown => None,
1175 })
1176 .collect();
1177 for r in resources_on_stack {
1178 state.consume_resource(r);
1179 }
1180 state.push_unknown(); }
1182
1183 _ => {
1187 }
1191 }
1192 }
1193
1194 fn analyze_if(
1196 &mut self,
1197 then_branch: &[Statement],
1198 else_branch: Option<&Vec<Statement>>,
1199 state: &mut StackState,
1200 word: &WordDef,
1201 ) {
1202 state.pop();
1204
1205 let mut then_state = state.clone();
1207 let mut else_state = state.clone();
1208
1209 self.analyze_statements(then_branch, &mut then_state, word);
1211
1212 if let Some(else_stmts) = else_branch {
1214 self.analyze_statements(else_stmts, &mut else_state, word);
1215 }
1216
1217 let merge_result = then_state.merge(&else_state);
1219 for inconsistent in merge_result.inconsistent {
1220 self.emit_branch_inconsistency_warning(&inconsistent, word);
1221 }
1222
1223 *state = then_state.join(&else_state);
1227 }
1228
1229 fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
1231 state.pop();
1233
1234 if arms.is_empty() {
1235 return;
1236 }
1237
1238 let mut arm_states: Vec<StackState> = Vec::new();
1240
1241 for arm in arms {
1242 let mut arm_state = state.clone();
1243
1244 match &arm.pattern {
1247 crate::ast::Pattern::Variant(_) => {
1248 }
1251 crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
1252 for _ in bindings {
1254 arm_state.push_unknown();
1255 }
1256 }
1257 }
1258
1259 self.analyze_statements(&arm.body, &mut arm_state, word);
1260 arm_states.push(arm_state);
1261 }
1262
1263 if arm_states.len() >= 2 {
1265 let first = &arm_states[0];
1266 for other in &arm_states[1..] {
1267 let merge_result = first.merge(other);
1268 for inconsistent in merge_result.inconsistent {
1269 self.emit_branch_inconsistency_warning(&inconsistent, word);
1270 }
1271 }
1272 }
1273
1274 if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
1277 *state = first;
1278 }
1279 }
1280
1281 fn emit_drop_warning(
1283 &mut self,
1284 resource: &TrackedResource,
1285 span: Option<&Span>,
1286 word: &WordDef,
1287 ) {
1288 let line = span
1289 .map(|s| s.line)
1290 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
1291 let column = span.map(|s| s.column);
1292
1293 self.diagnostics.push(LintDiagnostic {
1294 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
1295 message: format!(
1296 "{} created at line {} dropped without cleanup - {}",
1297 resource.kind.name(),
1298 resource.created_line + 1,
1299 resource.kind.cleanup_suggestion()
1300 ),
1301 severity: Severity::Warning,
1302 replacement: String::new(),
1303 file: self.file.clone(),
1304 line,
1305 end_line: None,
1306 start_column: column,
1307 end_column: column.map(|c| c + 4), word_name: word.name.clone(),
1309 start_index: 0,
1310 end_index: 0,
1311 });
1312 }
1313
1314 fn emit_branch_inconsistency_warning(
1316 &mut self,
1317 inconsistent: &InconsistentResource,
1318 word: &WordDef,
1319 ) {
1320 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
1321 let branch = if inconsistent.consumed_in_else {
1322 "else"
1323 } else {
1324 "then"
1325 };
1326
1327 self.diagnostics.push(LintDiagnostic {
1328 id: "resource-branch-inconsistent".to_string(),
1329 message: format!(
1330 "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
1331 inconsistent.resource.kind.name(),
1332 inconsistent.resource.created_line + 1,
1333 branch
1334 ),
1335 severity: Severity::Warning,
1336 replacement: String::new(),
1337 file: self.file.clone(),
1338 line,
1339 end_line: None,
1340 start_column: None,
1341 end_column: None,
1342 word_name: word.name.clone(),
1343 start_index: 0,
1344 end_index: 0,
1345 });
1346 }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use crate::ast::{Statement, WordDef};
1353
1354 fn make_word_call(name: &str) -> Statement {
1355 Statement::WordCall {
1356 name: name.to_string(),
1357 span: Some(Span::new(0, 0, name.len())),
1358 }
1359 }
1360
1361 #[test]
1362 fn test_immediate_weave_drop() {
1363 let word = WordDef {
1365 name: "bad".to_string(),
1366 effect: None,
1367 body: vec![
1368 Statement::Quotation {
1369 span: None,
1370 id: 0,
1371 body: vec![make_word_call("gen")],
1372 },
1373 make_word_call("strand.weave"),
1374 make_word_call("drop"),
1375 ],
1376 source: None,
1377 };
1378
1379 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1380 let diagnostics = analyzer.analyze_word(&word);
1381
1382 assert_eq!(diagnostics.len(), 1);
1383 assert!(diagnostics[0].id.contains("weavehandle"));
1384 assert!(diagnostics[0].message.contains("dropped without cleanup"));
1385 }
1386
1387 #[test]
1388 fn test_weave_properly_cancelled() {
1389 let word = WordDef {
1391 name: "good".to_string(),
1392 effect: None,
1393 body: vec![
1394 Statement::Quotation {
1395 span: None,
1396 id: 0,
1397 body: vec![make_word_call("gen")],
1398 },
1399 make_word_call("strand.weave"),
1400 make_word_call("strand.weave-cancel"),
1401 ],
1402 source: None,
1403 };
1404
1405 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1406 let diagnostics = analyzer.analyze_word(&word);
1407
1408 assert!(
1409 diagnostics.is_empty(),
1410 "Expected no warnings for properly cancelled weave"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_branch_inconsistent_handling() {
1416 let word = WordDef {
1420 name: "bad".to_string(),
1421 effect: None,
1422 body: vec![
1423 Statement::Quotation {
1424 span: None,
1425 id: 0,
1426 body: vec![make_word_call("gen")],
1427 },
1428 make_word_call("strand.weave"),
1429 Statement::BoolLiteral(true),
1430 Statement::If {
1431 then_branch: vec![make_word_call("strand.weave-cancel")],
1432 else_branch: Some(vec![make_word_call("drop")]),
1433 },
1434 ],
1435 source: None,
1436 };
1437
1438 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1439 let diagnostics = analyzer.analyze_word(&word);
1440
1441 assert!(!diagnostics.is_empty());
1443 }
1444
1445 #[test]
1446 fn test_both_branches_cancel() {
1447 let word = WordDef {
1451 name: "good".to_string(),
1452 effect: None,
1453 body: vec![
1454 Statement::Quotation {
1455 span: None,
1456 id: 0,
1457 body: vec![make_word_call("gen")],
1458 },
1459 make_word_call("strand.weave"),
1460 Statement::BoolLiteral(true),
1461 Statement::If {
1462 then_branch: vec![make_word_call("strand.weave-cancel")],
1463 else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
1464 },
1465 ],
1466 source: None,
1467 };
1468
1469 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1470 let diagnostics = analyzer.analyze_word(&word);
1471
1472 assert!(
1473 diagnostics.is_empty(),
1474 "Expected no warnings when both branches cancel"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_channel_leak() {
1480 let word = WordDef {
1482 name: "bad".to_string(),
1483 effect: None,
1484 body: vec![make_word_call("chan.make"), make_word_call("drop")],
1485 source: None,
1486 };
1487
1488 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1489 let diagnostics = analyzer.analyze_word(&word);
1490
1491 assert_eq!(diagnostics.len(), 1);
1492 assert!(diagnostics[0].id.contains("channel"));
1493 }
1494
1495 #[test]
1496 fn test_channel_properly_closed() {
1497 let word = WordDef {
1499 name: "good".to_string(),
1500 effect: None,
1501 body: vec![make_word_call("chan.make"), make_word_call("chan.close")],
1502 source: None,
1503 };
1504
1505 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1506 let diagnostics = analyzer.analyze_word(&word);
1507
1508 assert!(
1509 diagnostics.is_empty(),
1510 "Expected no warnings for properly closed channel"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_swap_resource_tracking() {
1516 let word = WordDef {
1520 name: "test".to_string(),
1521 effect: None,
1522 body: vec![
1523 make_word_call("chan.make"),
1524 Statement::IntLiteral(1),
1525 make_word_call("swap"),
1526 make_word_call("drop"), make_word_call("drop"), ],
1529 source: None,
1530 };
1531
1532 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1533 let diagnostics = analyzer.analyze_word(&word);
1534
1535 assert_eq!(
1536 diagnostics.len(),
1537 1,
1538 "Expected warning for dropped channel: {:?}",
1539 diagnostics
1540 );
1541 assert!(diagnostics[0].id.contains("channel"));
1542 }
1543
1544 #[test]
1545 fn test_over_resource_tracking() {
1546 let word = WordDef {
1552 name: "test".to_string(),
1553 effect: None,
1554 body: vec![
1555 make_word_call("chan.make"),
1556 Statement::IntLiteral(1),
1557 make_word_call("over"),
1558 make_word_call("drop"), make_word_call("drop"), make_word_call("drop"), ],
1562 source: None,
1563 };
1564
1565 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1566 let diagnostics = analyzer.analyze_word(&word);
1567
1568 assert_eq!(
1570 diagnostics.len(),
1571 2,
1572 "Expected 2 warnings for dropped channels: {:?}",
1573 diagnostics
1574 );
1575 }
1576
1577 #[test]
1578 fn test_channel_transferred_via_spawn() {
1579 let word = WordDef {
1587 name: "accept-loop".to_string(),
1588 effect: None,
1589 body: vec![
1590 make_word_call("chan.make"),
1591 make_word_call("dup"),
1592 Statement::Quotation {
1593 span: None,
1594 id: 0,
1595 body: vec![make_word_call("worker")],
1596 },
1597 make_word_call("strand.spawn"),
1598 make_word_call("drop"),
1599 make_word_call("drop"),
1600 make_word_call("chan.send"),
1601 ],
1602 source: None,
1603 };
1604
1605 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1606 let diagnostics = analyzer.analyze_word(&word);
1607
1608 assert!(
1609 diagnostics.is_empty(),
1610 "Expected no warnings when channel is transferred via strand.spawn: {:?}",
1611 diagnostics
1612 );
1613 }
1614
1615 #[test]
1616 fn test_else_branch_only_leak() {
1617 let word = WordDef {
1623 name: "test".to_string(),
1624 effect: None,
1625 body: vec![
1626 make_word_call("chan.make"),
1627 Statement::BoolLiteral(true),
1628 Statement::If {
1629 then_branch: vec![make_word_call("chan.close")],
1630 else_branch: Some(vec![make_word_call("drop")]),
1631 },
1632 ],
1633 source: None,
1634 };
1635
1636 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1637 let diagnostics = analyzer.analyze_word(&word);
1638
1639 assert!(
1641 !diagnostics.is_empty(),
1642 "Expected warnings for else-branch leak: {:?}",
1643 diagnostics
1644 );
1645 }
1646
1647 #[test]
1648 fn test_branch_join_both_consume() {
1649 let word = WordDef {
1654 name: "test".to_string(),
1655 effect: None,
1656 body: vec![
1657 make_word_call("chan.make"),
1658 Statement::BoolLiteral(true),
1659 Statement::If {
1660 then_branch: vec![make_word_call("chan.close")],
1661 else_branch: Some(vec![make_word_call("chan.close")]),
1662 },
1663 ],
1664 source: None,
1665 };
1666
1667 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1668 let diagnostics = analyzer.analyze_word(&word);
1669
1670 assert!(
1671 diagnostics.is_empty(),
1672 "Expected no warnings when both branches consume: {:?}",
1673 diagnostics
1674 );
1675 }
1676
1677 #[test]
1678 fn test_branch_join_neither_consume() {
1679 let word = WordDef {
1684 name: "test".to_string(),
1685 effect: None,
1686 body: vec![
1687 make_word_call("chan.make"),
1688 Statement::BoolLiteral(true),
1689 Statement::If {
1690 then_branch: vec![],
1691 else_branch: Some(vec![]),
1692 },
1693 make_word_call("drop"), ],
1695 source: None,
1696 };
1697
1698 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1699 let diagnostics = analyzer.analyze_word(&word);
1700
1701 assert_eq!(
1702 diagnostics.len(),
1703 1,
1704 "Expected warning for dropped channel: {:?}",
1705 diagnostics
1706 );
1707 assert!(diagnostics[0].id.contains("channel"));
1708 }
1709
1710 #[test]
1715 fn test_cross_word_resource_tracking() {
1716 use crate::ast::Program;
1723
1724 let make_chan = WordDef {
1725 name: "make-chan".to_string(),
1726 effect: None,
1727 body: vec![make_word_call("chan.make")],
1728 source: None,
1729 };
1730
1731 let leak_it = WordDef {
1732 name: "leak-it".to_string(),
1733 effect: None,
1734 body: vec![make_word_call("make-chan"), make_word_call("drop")],
1735 source: None,
1736 };
1737
1738 let program = Program {
1739 words: vec![make_chan, leak_it],
1740 includes: vec![],
1741 unions: vec![],
1742 };
1743
1744 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1745 let diagnostics = analyzer.analyze_program(&program);
1746
1747 assert_eq!(
1748 diagnostics.len(),
1749 1,
1750 "Expected warning for dropped channel from make-chan: {:?}",
1751 diagnostics
1752 );
1753 assert!(diagnostics[0].id.contains("channel"));
1754 assert!(diagnostics[0].message.contains("make-chan"));
1755 }
1756
1757 #[test]
1758 fn test_cross_word_proper_cleanup() {
1759 use crate::ast::Program;
1764
1765 let make_chan = WordDef {
1766 name: "make-chan".to_string(),
1767 effect: None,
1768 body: vec![make_word_call("chan.make")],
1769 source: None,
1770 };
1771
1772 let use_it = WordDef {
1773 name: "use-it".to_string(),
1774 effect: None,
1775 body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1776 source: None,
1777 };
1778
1779 let program = Program {
1780 words: vec![make_chan, use_it],
1781 includes: vec![],
1782 unions: vec![],
1783 };
1784
1785 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1786 let diagnostics = analyzer.analyze_program(&program);
1787
1788 assert!(
1789 diagnostics.is_empty(),
1790 "Expected no warnings for properly closed channel: {:?}",
1791 diagnostics
1792 );
1793 }
1794
1795 #[test]
1796 fn test_cross_word_chain() {
1797 use crate::ast::Program;
1803
1804 let make_chan = WordDef {
1805 name: "make-chan".to_string(),
1806 effect: None,
1807 body: vec![make_word_call("chan.make")],
1808 source: None,
1809 };
1810
1811 let wrap_chan = WordDef {
1812 name: "wrap-chan".to_string(),
1813 effect: None,
1814 body: vec![make_word_call("make-chan")],
1815 source: None,
1816 };
1817
1818 let leak_chain = WordDef {
1819 name: "leak-chain".to_string(),
1820 effect: None,
1821 body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1822 source: None,
1823 };
1824
1825 let program = Program {
1826 words: vec![make_chan, wrap_chan, leak_chain],
1827 includes: vec![],
1828 unions: vec![],
1829 };
1830
1831 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1832 let diagnostics = analyzer.analyze_program(&program);
1833
1834 assert_eq!(
1836 diagnostics.len(),
1837 1,
1838 "Expected warning for dropped channel through chain: {:?}",
1839 diagnostics
1840 );
1841 }
1842}