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 consumed: Vec<TrackedResource>,
95 next_id: usize,
97}
98
99impl Default for StackState {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl StackState {
106 pub fn new() -> Self {
107 StackState {
108 stack: Vec::new(),
109 consumed: Vec::new(),
110 next_id: 0,
111 }
112 }
113
114 pub fn push_unknown(&mut self) {
116 self.stack.push(StackValue::Unknown);
117 }
118
119 pub fn push_resource(&mut self, kind: ResourceKind, line: usize, word: &str) {
121 let resource = TrackedResource {
122 kind,
123 id: self.next_id,
124 created_line: line,
125 created_by: word.to_string(),
126 };
127 self.next_id += 1;
128 self.stack.push(StackValue::Resource(resource));
129 }
130
131 pub fn pop(&mut self) -> Option<StackValue> {
133 self.stack.pop()
134 }
135
136 pub fn peek(&self) -> Option<&StackValue> {
138 self.stack.last()
139 }
140
141 pub fn depth(&self) -> usize {
143 self.stack.len()
144 }
145
146 pub fn consume_resource(&mut self, resource: TrackedResource) {
148 self.consumed.push(resource);
149 }
150
151 pub fn remaining_resources(&self) -> Vec<&TrackedResource> {
153 self.stack
154 .iter()
155 .filter_map(|v| match v {
156 StackValue::Resource(r) => Some(r),
157 StackValue::Unknown => None,
158 })
159 .collect()
160 }
161
162 pub fn merge(&self, other: &StackState) -> BranchMergeResult {
165 let self_resources: HashMap<usize, &TrackedResource> = self
166 .stack
167 .iter()
168 .filter_map(|v| match v {
169 StackValue::Resource(r) => Some((r.id, r)),
170 StackValue::Unknown => None,
171 })
172 .collect();
173
174 let other_resources: HashMap<usize, &TrackedResource> = other
175 .stack
176 .iter()
177 .filter_map(|v| match v {
178 StackValue::Resource(r) => Some((r.id, r)),
179 StackValue::Unknown => None,
180 })
181 .collect();
182
183 let self_consumed: std::collections::HashSet<usize> =
184 self.consumed.iter().map(|r| r.id).collect();
185 let other_consumed: std::collections::HashSet<usize> =
186 other.consumed.iter().map(|r| r.id).collect();
187
188 let mut inconsistent = Vec::new();
189
190 for (id, resource) in &self_resources {
192 if other_consumed.contains(id) && !self_consumed.contains(id) {
193 inconsistent.push(InconsistentResource {
195 resource: (*resource).clone(),
196 consumed_in_else: true,
197 });
198 }
199 }
200
201 for (id, resource) in &other_resources {
202 if self_consumed.contains(id) && !other_consumed.contains(id) {
203 inconsistent.push(InconsistentResource {
205 resource: (*resource).clone(),
206 consumed_in_else: false,
207 });
208 }
209 }
210
211 BranchMergeResult { inconsistent }
212 }
213
214 pub fn join(&self, other: &StackState) -> StackState {
223 let other_consumed: std::collections::HashSet<usize> =
225 other.consumed.iter().map(|r| r.id).collect();
226
227 let definitely_consumed: Vec<TrackedResource> = self
229 .consumed
230 .iter()
231 .filter(|r| other_consumed.contains(&r.id))
232 .cloned()
233 .collect();
234
235 let mut joined_stack = self.stack.clone();
243
244 let other_resources: HashMap<usize, TrackedResource> = other
246 .stack
247 .iter()
248 .filter_map(|v| match v {
249 StackValue::Resource(r) => Some((r.id, r.clone())),
250 StackValue::Unknown => None,
251 })
252 .collect();
253
254 for (i, val) in joined_stack.iter_mut().enumerate() {
256 if matches!(val, StackValue::Unknown)
257 && i < other.stack.len()
258 && let StackValue::Resource(r) = &other.stack[i]
259 {
260 *val = StackValue::Resource(r.clone());
261 }
262 }
263
264 let self_resource_ids: std::collections::HashSet<usize> = joined_stack
267 .iter()
268 .filter_map(|v| match v {
269 StackValue::Resource(r) => Some(r.id),
270 StackValue::Unknown => None,
271 })
272 .collect();
273
274 for (id, resource) in other_resources {
275 if !self_resource_ids.contains(&id) && !definitely_consumed.iter().any(|r| r.id == id) {
276 joined_stack.push(StackValue::Resource(resource));
279 }
280 }
281
282 StackState {
283 stack: joined_stack,
284 consumed: definitely_consumed,
285 next_id: self.next_id.max(other.next_id),
286 }
287 }
288}
289
290#[derive(Debug)]
292pub(crate) struct BranchMergeResult {
293 pub inconsistent: Vec<InconsistentResource>,
295}
296
297#[derive(Debug)]
299pub(crate) struct InconsistentResource {
300 pub resource: TrackedResource,
301 pub consumed_in_else: bool,
303}
304
305#[derive(Debug, Clone, Default)]
311pub(crate) struct WordResourceInfo {
312 pub returns: Vec<ResourceKind>,
314}
315
316pub struct ProgramResourceAnalyzer {
322 word_info: HashMap<String, WordResourceInfo>,
324 file: std::path::PathBuf,
326 diagnostics: Vec<LintDiagnostic>,
328}
329
330impl ProgramResourceAnalyzer {
331 pub fn new(file: &Path) -> Self {
332 ProgramResourceAnalyzer {
333 word_info: HashMap::new(),
334 file: file.to_path_buf(),
335 diagnostics: Vec::new(),
336 }
337 }
338
339 pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
341 self.diagnostics.clear();
342 self.word_info.clear();
343
344 for word in &program.words {
346 let info = self.collect_word_info(word);
347 self.word_info.insert(word.name.clone(), info);
348 }
349
350 for word in &program.words {
352 self.analyze_word_with_context(word);
353 }
354
355 std::mem::take(&mut self.diagnostics)
356 }
357
358 fn collect_word_info(&self, word: &WordDef) -> WordResourceInfo {
360 let mut state = StackState::new();
361
362 self.simulate_statements(&word.body, &mut state);
364
365 let returns: Vec<ResourceKind> = state
367 .remaining_resources()
368 .into_iter()
369 .map(|r| r.kind)
370 .collect();
371
372 WordResourceInfo { returns }
373 }
374
375 fn simulate_statements(&self, statements: &[Statement], state: &mut StackState) {
377 for stmt in statements {
378 self.simulate_statement(stmt, state);
379 }
380 }
381
382 fn simulate_statement(&self, stmt: &Statement, state: &mut StackState) {
384 match stmt {
385 Statement::IntLiteral(_)
386 | Statement::FloatLiteral(_)
387 | Statement::BoolLiteral(_)
388 | Statement::StringLiteral(_)
389 | Statement::Symbol(_) => {
390 state.push_unknown();
391 }
392
393 Statement::WordCall { name, span } => {
394 self.simulate_word_call(name, span.as_ref(), state);
395 }
396
397 Statement::Quotation { .. } => {
398 state.push_unknown();
399 }
400
401 Statement::If {
402 then_branch,
403 else_branch,
404 } => {
405 state.pop(); let mut then_state = state.clone();
407 let mut else_state = state.clone();
408 self.simulate_statements(then_branch, &mut then_state);
409 if let Some(else_stmts) = else_branch {
410 self.simulate_statements(else_stmts, &mut else_state);
411 }
412 *state = then_state.join(&else_state);
413 }
414
415 Statement::Match { arms } => {
416 state.pop();
417 let mut arm_states: Vec<StackState> = Vec::new();
418 for arm in arms {
419 let mut arm_state = state.clone();
420 self.simulate_statements(&arm.body, &mut arm_state);
421 arm_states.push(arm_state);
422 }
423 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
424 *state = joined;
425 }
426 }
427 }
428 }
429
430 fn simulate_word_common<F>(
438 name: &str,
439 span: Option<&Span>,
440 state: &mut StackState,
441 word_info: &HashMap<String, WordResourceInfo>,
442 mut on_resource_dropped: F,
443 ) -> bool
444 where
445 F: FnMut(&TrackedResource),
446 {
447 let line = span.map(|s| s.line).unwrap_or(0);
448
449 match name {
450 "strand.weave" => {
452 state.pop();
453 state.push_resource(ResourceKind::WeaveHandle, line, name);
454 }
455 "chan.make" => {
456 state.push_resource(ResourceKind::Channel, line, name);
457 }
458
459 "strand.weave-cancel" => {
461 if let Some(StackValue::Resource(r)) = state.pop()
462 && r.kind == ResourceKind::WeaveHandle
463 {
464 state.consume_resource(r);
465 }
466 }
467 "chan.close" => {
468 if let Some(StackValue::Resource(r)) = state.pop()
469 && r.kind == ResourceKind::Channel
470 {
471 state.consume_resource(r);
472 }
473 }
474
475 "drop" => {
477 let dropped = state.pop();
478 if let Some(StackValue::Resource(r)) = dropped {
479 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
481 if !already_consumed {
482 on_resource_dropped(&r);
483 }
484 }
485 }
486 "dup" => {
487 if let Some(top) = state.peek().cloned() {
490 state.stack.push(top);
491 }
492 }
493 "swap" => {
494 let a = state.pop();
495 let b = state.pop();
496 if let Some(av) = a {
497 state.stack.push(av);
498 }
499 if let Some(bv) = b {
500 state.stack.push(bv);
501 }
502 }
503 "over" => {
504 if state.depth() >= 2 {
506 let second = state.stack[state.depth() - 2].clone();
507 state.stack.push(second);
508 }
509 }
510 "rot" => {
511 let c = state.pop();
513 let b = state.pop();
514 let a = state.pop();
515 if let Some(bv) = b {
516 state.stack.push(bv);
517 }
518 if let Some(cv) = c {
519 state.stack.push(cv);
520 }
521 if let Some(av) = a {
522 state.stack.push(av);
523 }
524 }
525 "nip" => {
526 let b = state.pop();
528 let a = state.pop();
529 if let Some(StackValue::Resource(r)) = a {
530 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
531 if !already_consumed {
532 on_resource_dropped(&r);
533 }
534 }
535 if let Some(bv) = b {
536 state.stack.push(bv);
537 }
538 }
539 "tuck" => {
540 let b = state.pop();
542 let a = state.pop();
543 if let Some(bv) = b.clone() {
544 state.stack.push(bv);
545 }
546 if let Some(av) = a {
547 state.stack.push(av);
548 }
549 if let Some(bv) = b {
550 state.stack.push(bv);
551 }
552 }
553
554 "strand.spawn" => {
556 state.pop();
557 let resources: Vec<TrackedResource> = state
558 .stack
559 .iter()
560 .filter_map(|v| match v {
561 StackValue::Resource(r) => Some(r.clone()),
562 StackValue::Unknown => None,
563 })
564 .collect();
565 for r in resources {
566 state.consume_resource(r);
567 }
568 state.push_unknown();
569 }
570
571 "map.set" => {
573 let value = state.pop();
575 state.pop(); state.pop(); if let Some(StackValue::Resource(r)) = value {
579 state.consume_resource(r);
580 }
581 state.push_unknown(); }
583
584 "list.push" | "list.prepend" => {
586 let value = state.pop();
588 state.pop(); if let Some(StackValue::Resource(r)) = value {
590 state.consume_resource(r);
591 }
592 state.push_unknown(); }
594
595 _ => {
597 if let Some(info) = word_info.get(name) {
598 for kind in &info.returns {
600 state.push_resource(*kind, line, name);
601 }
602 return true;
603 }
604 return false;
606 }
607 }
608 true
609 }
610
611 fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
613 Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
615 }
616
617 fn analyze_word_with_context(&mut self, word: &WordDef) {
619 let mut state = StackState::new();
620
621 self.analyze_statements_with_context(&word.body, &mut state, word);
622
623 }
625
626 fn analyze_statements_with_context(
628 &mut self,
629 statements: &[Statement],
630 state: &mut StackState,
631 word: &WordDef,
632 ) {
633 for stmt in statements {
634 self.analyze_statement_with_context(stmt, state, word);
635 }
636 }
637
638 fn analyze_statement_with_context(
640 &mut self,
641 stmt: &Statement,
642 state: &mut StackState,
643 word: &WordDef,
644 ) {
645 match stmt {
646 Statement::IntLiteral(_)
647 | Statement::FloatLiteral(_)
648 | Statement::BoolLiteral(_)
649 | Statement::StringLiteral(_)
650 | Statement::Symbol(_) => {
651 state.push_unknown();
652 }
653
654 Statement::WordCall { name, span } => {
655 self.analyze_word_call_with_context(name, span.as_ref(), state, word);
656 }
657
658 Statement::Quotation { .. } => {
659 state.push_unknown();
660 }
661
662 Statement::If {
663 then_branch,
664 else_branch,
665 } => {
666 state.pop();
667 let mut then_state = state.clone();
668 let mut else_state = state.clone();
669
670 self.analyze_statements_with_context(then_branch, &mut then_state, word);
671 if let Some(else_stmts) = else_branch {
672 self.analyze_statements_with_context(else_stmts, &mut else_state, word);
673 }
674
675 let merge_result = then_state.merge(&else_state);
677 for inconsistent in merge_result.inconsistent {
678 self.emit_branch_inconsistency_warning(&inconsistent, word);
679 }
680
681 *state = then_state.join(&else_state);
682 }
683
684 Statement::Match { arms } => {
685 state.pop();
686 let mut arm_states: Vec<StackState> = Vec::new();
687
688 for arm in arms {
689 let mut arm_state = state.clone();
690 self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
691 arm_states.push(arm_state);
692 }
693
694 if arm_states.len() >= 2 {
696 let first = &arm_states[0];
697 for other in &arm_states[1..] {
698 let merge_result = first.merge(other);
699 for inconsistent in merge_result.inconsistent {
700 self.emit_branch_inconsistency_warning(&inconsistent, word);
701 }
702 }
703 }
704
705 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
706 *state = joined;
707 }
708 }
709 }
710 }
711
712 fn analyze_word_call_with_context(
714 &mut self,
715 name: &str,
716 span: Option<&Span>,
717 state: &mut StackState,
718 word: &WordDef,
719 ) {
720 let mut dropped_resources: Vec<TrackedResource> = Vec::new();
722
723 let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
725 dropped_resources.push(r.clone())
726 });
727
728 for r in dropped_resources {
730 self.emit_drop_warning(&r, span, word);
731 }
732
733 if handled {
734 return;
735 }
736
737 match name {
739 "strand.resume" => {
741 let value = state.pop();
742 let handle = state.pop();
743 if let Some(h) = handle {
744 state.stack.push(h);
745 } else {
746 state.push_unknown();
747 }
748 if let Some(v) = value {
749 state.stack.push(v);
750 } else {
751 state.push_unknown();
752 }
753 state.push_unknown();
754 }
755
756 "2dup" => {
757 if state.depth() >= 2 {
758 let b = state.stack[state.depth() - 1].clone();
759 let a = state.stack[state.depth() - 2].clone();
760 state.stack.push(a);
761 state.stack.push(b);
762 } else {
763 state.push_unknown();
764 state.push_unknown();
765 }
766 }
767
768 "3drop" => {
769 for _ in 0..3 {
770 if let Some(StackValue::Resource(r)) = state.pop() {
771 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
772 if !already_consumed {
773 self.emit_drop_warning(&r, span, word);
774 }
775 }
776 }
777 }
778
779 "pick" | "roll" => {
780 state.pop();
781 state.push_unknown();
782 }
783
784 "chan.send" | "chan.receive" => {
785 state.pop();
786 state.pop();
787 state.push_unknown();
788 state.push_unknown();
789 }
790
791 _ => {}
793 }
794 }
795
796 fn emit_drop_warning(
797 &mut self,
798 resource: &TrackedResource,
799 span: Option<&Span>,
800 word: &WordDef,
801 ) {
802 let line = span
803 .map(|s| s.line)
804 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
805 let column = span.map(|s| s.column);
806
807 self.diagnostics.push(LintDiagnostic {
808 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
809 message: format!(
810 "{} from `{}` (line {}) dropped without cleanup - {}",
811 resource.kind.name(),
812 resource.created_by,
813 resource.created_line + 1,
814 resource.kind.cleanup_suggestion()
815 ),
816 severity: Severity::Warning,
817 replacement: String::new(),
818 file: self.file.clone(),
819 line,
820 end_line: None,
821 start_column: column,
822 end_column: column.map(|c| c + 4),
823 word_name: word.name.clone(),
824 start_index: 0,
825 end_index: 0,
826 });
827 }
828
829 fn emit_branch_inconsistency_warning(
830 &mut self,
831 inconsistent: &InconsistentResource,
832 word: &WordDef,
833 ) {
834 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
835 let branch = if inconsistent.consumed_in_else {
836 "else"
837 } else {
838 "then"
839 };
840
841 self.diagnostics.push(LintDiagnostic {
842 id: "resource-branch-inconsistent".to_string(),
843 message: format!(
844 "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
845 inconsistent.resource.kind.name(),
846 inconsistent.resource.created_by,
847 inconsistent.resource.created_line + 1,
848 branch
849 ),
850 severity: Severity::Warning,
851 replacement: String::new(),
852 file: self.file.clone(),
853 line,
854 end_line: None,
855 start_column: None,
856 end_column: None,
857 word_name: word.name.clone(),
858 start_index: 0,
859 end_index: 0,
860 });
861 }
862}
863
864pub struct ResourceAnalyzer {
866 diagnostics: Vec<LintDiagnostic>,
868 file: std::path::PathBuf,
870}
871
872impl ResourceAnalyzer {
873 pub fn new(file: &Path) -> Self {
874 ResourceAnalyzer {
875 diagnostics: Vec::new(),
876 file: file.to_path_buf(),
877 }
878 }
879
880 pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
882 self.diagnostics.clear();
883
884 let mut state = StackState::new();
885
886 self.analyze_statements(&word.body, &mut state, word);
888
889 let _ = state.remaining_resources(); std::mem::take(&mut self.diagnostics)
905 }
906
907 fn analyze_statements(
909 &mut self,
910 statements: &[Statement],
911 state: &mut StackState,
912 word: &WordDef,
913 ) {
914 for stmt in statements {
915 self.analyze_statement(stmt, state, word);
916 }
917 }
918
919 fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
921 match stmt {
922 Statement::IntLiteral(_)
923 | Statement::FloatLiteral(_)
924 | Statement::BoolLiteral(_)
925 | Statement::StringLiteral(_)
926 | Statement::Symbol(_) => {
927 state.push_unknown();
928 }
929
930 Statement::WordCall { name, span } => {
931 self.analyze_word_call(name, span.as_ref(), state, word);
932 }
933
934 Statement::Quotation { body, .. } => {
935 let _ = body; state.push_unknown();
941 }
942
943 Statement::If {
944 then_branch,
945 else_branch,
946 } => {
947 self.analyze_if(then_branch, else_branch.as_ref(), state, word);
948 }
949
950 Statement::Match { arms } => {
951 self.analyze_match(arms, state, word);
952 }
953 }
954 }
955
956 fn analyze_word_call(
958 &mut self,
959 name: &str,
960 span: Option<&Span>,
961 state: &mut StackState,
962 word: &WordDef,
963 ) {
964 let line = span.map(|s| s.line).unwrap_or(0);
965
966 match name {
967 "strand.weave" => {
969 state.pop(); state.push_resource(ResourceKind::WeaveHandle, line, name);
972 }
973
974 "chan.make" => {
975 state.push_resource(ResourceKind::Channel, line, name);
977 }
978
979 "strand.weave-cancel" => {
981 if let Some(StackValue::Resource(r)) = state.pop()
983 && r.kind == ResourceKind::WeaveHandle
984 {
985 state.consume_resource(r);
986 }
987 }
988
989 "chan.close" => {
990 if let Some(StackValue::Resource(r)) = state.pop()
992 && r.kind == ResourceKind::Channel
993 {
994 state.consume_resource(r);
995 }
996 }
997
998 "strand.resume" => {
1003 let value = state.pop(); let handle = state.pop(); if let Some(h) = handle {
1009 state.stack.push(h);
1010 } else {
1011 state.push_unknown();
1012 }
1013 if let Some(v) = value {
1014 state.stack.push(v);
1015 } else {
1016 state.push_unknown();
1017 }
1018 state.push_unknown(); }
1020
1021 "drop" => {
1023 let dropped = state.pop();
1024 if let Some(StackValue::Resource(r)) = dropped {
1027 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1028 if !already_consumed {
1029 self.emit_drop_warning(&r, span, word);
1030 }
1031 }
1032 }
1033
1034 "dup" => {
1035 if let Some(top) = state.peek().cloned() {
1036 state.stack.push(top);
1037 } else {
1038 state.push_unknown();
1039 }
1040 }
1041
1042 "swap" => {
1043 let a = state.pop();
1044 let b = state.pop();
1045 if let Some(av) = a {
1046 state.stack.push(av);
1047 }
1048 if let Some(bv) = b {
1049 state.stack.push(bv);
1050 }
1051 }
1052
1053 "over" => {
1054 if state.depth() >= 2 {
1056 let second = state.stack[state.depth() - 2].clone();
1057 state.stack.push(second);
1058 } else {
1059 state.push_unknown();
1060 }
1061 }
1062
1063 "rot" => {
1064 let c = state.pop();
1066 let b = state.pop();
1067 let a = state.pop();
1068 if let Some(bv) = b {
1069 state.stack.push(bv);
1070 }
1071 if let Some(cv) = c {
1072 state.stack.push(cv);
1073 }
1074 if let Some(av) = a {
1075 state.stack.push(av);
1076 }
1077 }
1078
1079 "nip" => {
1080 let b = state.pop();
1082 let a = state.pop();
1083 if let Some(StackValue::Resource(r)) = a {
1084 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1085 if !already_consumed {
1086 self.emit_drop_warning(&r, span, word);
1087 }
1088 }
1089 if let Some(bv) = b {
1090 state.stack.push(bv);
1091 }
1092 }
1093
1094 "tuck" => {
1095 let b = state.pop();
1097 let a = state.pop();
1098 if let Some(bv) = b.clone() {
1099 state.stack.push(bv);
1100 }
1101 if let Some(av) = a {
1102 state.stack.push(av);
1103 }
1104 if let Some(bv) = b {
1105 state.stack.push(bv);
1106 }
1107 }
1108
1109 "2dup" => {
1110 if state.depth() >= 2 {
1112 let b = state.stack[state.depth() - 1].clone();
1113 let a = state.stack[state.depth() - 2].clone();
1114 state.stack.push(a);
1115 state.stack.push(b);
1116 } else {
1117 state.push_unknown();
1118 state.push_unknown();
1119 }
1120 }
1121
1122 "3drop" => {
1123 for _ in 0..3 {
1124 if let Some(StackValue::Resource(r)) = state.pop() {
1125 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1126 if !already_consumed {
1127 self.emit_drop_warning(&r, span, word);
1128 }
1129 }
1130 }
1131 }
1132
1133 "pick" => {
1134 state.pop(); state.push_unknown();
1138 }
1139
1140 "roll" => {
1141 state.pop(); state.push_unknown();
1144 }
1145
1146 "chan.send" | "chan.receive" => {
1148 state.pop();
1152 state.pop();
1153 state.push_unknown();
1154 state.push_unknown();
1155 }
1156
1157 "strand.spawn" => {
1160 state.pop(); let resources_on_stack: Vec<TrackedResource> = state
1165 .stack
1166 .iter()
1167 .filter_map(|v| match v {
1168 StackValue::Resource(r) => Some(r.clone()),
1169 StackValue::Unknown => None,
1170 })
1171 .collect();
1172 for r in resources_on_stack {
1173 state.consume_resource(r);
1174 }
1175 state.push_unknown(); }
1177
1178 _ => {
1182 }
1186 }
1187 }
1188
1189 fn analyze_if(
1191 &mut self,
1192 then_branch: &[Statement],
1193 else_branch: Option<&Vec<Statement>>,
1194 state: &mut StackState,
1195 word: &WordDef,
1196 ) {
1197 state.pop();
1199
1200 let mut then_state = state.clone();
1202 let mut else_state = state.clone();
1203
1204 self.analyze_statements(then_branch, &mut then_state, word);
1206
1207 if let Some(else_stmts) = else_branch {
1209 self.analyze_statements(else_stmts, &mut else_state, word);
1210 }
1211
1212 let merge_result = then_state.merge(&else_state);
1214 for inconsistent in merge_result.inconsistent {
1215 self.emit_branch_inconsistency_warning(&inconsistent, word);
1216 }
1217
1218 *state = then_state.join(&else_state);
1222 }
1223
1224 fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
1226 state.pop();
1228
1229 if arms.is_empty() {
1230 return;
1231 }
1232
1233 let mut arm_states: Vec<StackState> = Vec::new();
1235
1236 for arm in arms {
1237 let mut arm_state = state.clone();
1238
1239 match &arm.pattern {
1242 crate::ast::Pattern::Variant(_) => {
1243 }
1246 crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
1247 for _ in bindings {
1249 arm_state.push_unknown();
1250 }
1251 }
1252 }
1253
1254 self.analyze_statements(&arm.body, &mut arm_state, word);
1255 arm_states.push(arm_state);
1256 }
1257
1258 if arm_states.len() >= 2 {
1260 let first = &arm_states[0];
1261 for other in &arm_states[1..] {
1262 let merge_result = first.merge(other);
1263 for inconsistent in merge_result.inconsistent {
1264 self.emit_branch_inconsistency_warning(&inconsistent, word);
1265 }
1266 }
1267 }
1268
1269 if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
1272 *state = first;
1273 }
1274 }
1275
1276 fn emit_drop_warning(
1278 &mut self,
1279 resource: &TrackedResource,
1280 span: Option<&Span>,
1281 word: &WordDef,
1282 ) {
1283 let line = span
1284 .map(|s| s.line)
1285 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
1286 let column = span.map(|s| s.column);
1287
1288 self.diagnostics.push(LintDiagnostic {
1289 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
1290 message: format!(
1291 "{} created at line {} dropped without cleanup - {}",
1292 resource.kind.name(),
1293 resource.created_line + 1,
1294 resource.kind.cleanup_suggestion()
1295 ),
1296 severity: Severity::Warning,
1297 replacement: String::new(),
1298 file: self.file.clone(),
1299 line,
1300 end_line: None,
1301 start_column: column,
1302 end_column: column.map(|c| c + 4), word_name: word.name.clone(),
1304 start_index: 0,
1305 end_index: 0,
1306 });
1307 }
1308
1309 fn emit_branch_inconsistency_warning(
1311 &mut self,
1312 inconsistent: &InconsistentResource,
1313 word: &WordDef,
1314 ) {
1315 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
1316 let branch = if inconsistent.consumed_in_else {
1317 "else"
1318 } else {
1319 "then"
1320 };
1321
1322 self.diagnostics.push(LintDiagnostic {
1323 id: "resource-branch-inconsistent".to_string(),
1324 message: format!(
1325 "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
1326 inconsistent.resource.kind.name(),
1327 inconsistent.resource.created_line + 1,
1328 branch
1329 ),
1330 severity: Severity::Warning,
1331 replacement: String::new(),
1332 file: self.file.clone(),
1333 line,
1334 end_line: None,
1335 start_column: None,
1336 end_column: None,
1337 word_name: word.name.clone(),
1338 start_index: 0,
1339 end_index: 0,
1340 });
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use crate::ast::{Statement, WordDef};
1348
1349 fn make_word_call(name: &str) -> Statement {
1350 Statement::WordCall {
1351 name: name.to_string(),
1352 span: Some(Span::new(0, 0, name.len())),
1353 }
1354 }
1355
1356 #[test]
1357 fn test_immediate_weave_drop() {
1358 let word = WordDef {
1360 name: "bad".to_string(),
1361 effect: None,
1362 body: vec![
1363 Statement::Quotation {
1364 span: None,
1365 id: 0,
1366 body: vec![make_word_call("gen")],
1367 },
1368 make_word_call("strand.weave"),
1369 make_word_call("drop"),
1370 ],
1371 source: None,
1372 allowed_lints: vec![],
1373 };
1374
1375 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1376 let diagnostics = analyzer.analyze_word(&word);
1377
1378 assert_eq!(diagnostics.len(), 1);
1379 assert!(diagnostics[0].id.contains("weavehandle"));
1380 assert!(diagnostics[0].message.contains("dropped without cleanup"));
1381 }
1382
1383 #[test]
1384 fn test_weave_properly_cancelled() {
1385 let word = WordDef {
1387 name: "good".to_string(),
1388 effect: None,
1389 body: vec![
1390 Statement::Quotation {
1391 span: None,
1392 id: 0,
1393 body: vec![make_word_call("gen")],
1394 },
1395 make_word_call("strand.weave"),
1396 make_word_call("strand.weave-cancel"),
1397 ],
1398 source: None,
1399 allowed_lints: vec![],
1400 };
1401
1402 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1403 let diagnostics = analyzer.analyze_word(&word);
1404
1405 assert!(
1406 diagnostics.is_empty(),
1407 "Expected no warnings for properly cancelled weave"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_branch_inconsistent_handling() {
1413 let word = WordDef {
1417 name: "bad".to_string(),
1418 effect: None,
1419 body: vec![
1420 Statement::Quotation {
1421 span: None,
1422 id: 0,
1423 body: vec![make_word_call("gen")],
1424 },
1425 make_word_call("strand.weave"),
1426 Statement::BoolLiteral(true),
1427 Statement::If {
1428 then_branch: vec![make_word_call("strand.weave-cancel")],
1429 else_branch: Some(vec![make_word_call("drop")]),
1430 },
1431 ],
1432 source: None,
1433 allowed_lints: vec![],
1434 };
1435
1436 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1437 let diagnostics = analyzer.analyze_word(&word);
1438
1439 assert!(!diagnostics.is_empty());
1441 }
1442
1443 #[test]
1444 fn test_both_branches_cancel() {
1445 let word = WordDef {
1449 name: "good".to_string(),
1450 effect: None,
1451 body: vec![
1452 Statement::Quotation {
1453 span: None,
1454 id: 0,
1455 body: vec![make_word_call("gen")],
1456 },
1457 make_word_call("strand.weave"),
1458 Statement::BoolLiteral(true),
1459 Statement::If {
1460 then_branch: vec![make_word_call("strand.weave-cancel")],
1461 else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
1462 },
1463 ],
1464 source: None,
1465 allowed_lints: vec![],
1466 };
1467
1468 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1469 let diagnostics = analyzer.analyze_word(&word);
1470
1471 assert!(
1472 diagnostics.is_empty(),
1473 "Expected no warnings when both branches cancel"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_channel_leak() {
1479 let word = WordDef {
1481 name: "bad".to_string(),
1482 effect: None,
1483 body: vec![make_word_call("chan.make"), make_word_call("drop")],
1484 source: None,
1485 allowed_lints: vec![],
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 allowed_lints: vec![],
1504 };
1505
1506 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1507 let diagnostics = analyzer.analyze_word(&word);
1508
1509 assert!(
1510 diagnostics.is_empty(),
1511 "Expected no warnings for properly closed channel"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_swap_resource_tracking() {
1517 let word = WordDef {
1521 name: "test".to_string(),
1522 effect: None,
1523 body: vec![
1524 make_word_call("chan.make"),
1525 Statement::IntLiteral(1),
1526 make_word_call("swap"),
1527 make_word_call("drop"), make_word_call("drop"), ],
1530 source: None,
1531 allowed_lints: vec![],
1532 };
1533
1534 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1535 let diagnostics = analyzer.analyze_word(&word);
1536
1537 assert_eq!(
1538 diagnostics.len(),
1539 1,
1540 "Expected warning for dropped channel: {:?}",
1541 diagnostics
1542 );
1543 assert!(diagnostics[0].id.contains("channel"));
1544 }
1545
1546 #[test]
1547 fn test_over_resource_tracking() {
1548 let word = WordDef {
1554 name: "test".to_string(),
1555 effect: None,
1556 body: vec![
1557 make_word_call("chan.make"),
1558 Statement::IntLiteral(1),
1559 make_word_call("over"),
1560 make_word_call("drop"), make_word_call("drop"), make_word_call("drop"), ],
1564 source: None,
1565 allowed_lints: vec![],
1566 };
1567
1568 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1569 let diagnostics = analyzer.analyze_word(&word);
1570
1571 assert_eq!(
1573 diagnostics.len(),
1574 2,
1575 "Expected 2 warnings for dropped channels: {:?}",
1576 diagnostics
1577 );
1578 }
1579
1580 #[test]
1581 fn test_channel_transferred_via_spawn() {
1582 let word = WordDef {
1590 name: "accept-loop".to_string(),
1591 effect: None,
1592 body: vec![
1593 make_word_call("chan.make"),
1594 make_word_call("dup"),
1595 Statement::Quotation {
1596 span: None,
1597 id: 0,
1598 body: vec![make_word_call("worker")],
1599 },
1600 make_word_call("strand.spawn"),
1601 make_word_call("drop"),
1602 make_word_call("drop"),
1603 make_word_call("chan.send"),
1604 ],
1605 source: None,
1606 allowed_lints: vec![],
1607 };
1608
1609 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1610 let diagnostics = analyzer.analyze_word(&word);
1611
1612 assert!(
1613 diagnostics.is_empty(),
1614 "Expected no warnings when channel is transferred via strand.spawn: {:?}",
1615 diagnostics
1616 );
1617 }
1618
1619 #[test]
1620 fn test_else_branch_only_leak() {
1621 let word = WordDef {
1627 name: "test".to_string(),
1628 effect: None,
1629 body: vec![
1630 make_word_call("chan.make"),
1631 Statement::BoolLiteral(true),
1632 Statement::If {
1633 then_branch: vec![make_word_call("chan.close")],
1634 else_branch: Some(vec![make_word_call("drop")]),
1635 },
1636 ],
1637 source: None,
1638 allowed_lints: vec![],
1639 };
1640
1641 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1642 let diagnostics = analyzer.analyze_word(&word);
1643
1644 assert!(
1646 !diagnostics.is_empty(),
1647 "Expected warnings for else-branch leak: {:?}",
1648 diagnostics
1649 );
1650 }
1651
1652 #[test]
1653 fn test_branch_join_both_consume() {
1654 let word = WordDef {
1659 name: "test".to_string(),
1660 effect: None,
1661 body: vec![
1662 make_word_call("chan.make"),
1663 Statement::BoolLiteral(true),
1664 Statement::If {
1665 then_branch: vec![make_word_call("chan.close")],
1666 else_branch: Some(vec![make_word_call("chan.close")]),
1667 },
1668 ],
1669 source: None,
1670 allowed_lints: vec![],
1671 };
1672
1673 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1674 let diagnostics = analyzer.analyze_word(&word);
1675
1676 assert!(
1677 diagnostics.is_empty(),
1678 "Expected no warnings when both branches consume: {:?}",
1679 diagnostics
1680 );
1681 }
1682
1683 #[test]
1684 fn test_branch_join_neither_consume() {
1685 let word = WordDef {
1690 name: "test".to_string(),
1691 effect: None,
1692 body: vec![
1693 make_word_call("chan.make"),
1694 Statement::BoolLiteral(true),
1695 Statement::If {
1696 then_branch: vec![],
1697 else_branch: Some(vec![]),
1698 },
1699 make_word_call("drop"), ],
1701 source: None,
1702 allowed_lints: vec![],
1703 };
1704
1705 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1706 let diagnostics = analyzer.analyze_word(&word);
1707
1708 assert_eq!(
1709 diagnostics.len(),
1710 1,
1711 "Expected warning for dropped channel: {:?}",
1712 diagnostics
1713 );
1714 assert!(diagnostics[0].id.contains("channel"));
1715 }
1716
1717 #[test]
1722 fn test_cross_word_resource_tracking() {
1723 use crate::ast::Program;
1730
1731 let make_chan = WordDef {
1732 name: "make-chan".to_string(),
1733 effect: None,
1734 body: vec![make_word_call("chan.make")],
1735 source: None,
1736 allowed_lints: vec![],
1737 };
1738
1739 let leak_it = WordDef {
1740 name: "leak-it".to_string(),
1741 effect: None,
1742 body: vec![make_word_call("make-chan"), make_word_call("drop")],
1743 source: None,
1744 allowed_lints: vec![],
1745 };
1746
1747 let program = Program {
1748 words: vec![make_chan, leak_it],
1749 includes: vec![],
1750 unions: vec![],
1751 };
1752
1753 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1754 let diagnostics = analyzer.analyze_program(&program);
1755
1756 assert_eq!(
1757 diagnostics.len(),
1758 1,
1759 "Expected warning for dropped channel from make-chan: {:?}",
1760 diagnostics
1761 );
1762 assert!(diagnostics[0].id.contains("channel"));
1763 assert!(diagnostics[0].message.contains("make-chan"));
1764 }
1765
1766 #[test]
1767 fn test_cross_word_proper_cleanup() {
1768 use crate::ast::Program;
1773
1774 let make_chan = WordDef {
1775 name: "make-chan".to_string(),
1776 effect: None,
1777 body: vec![make_word_call("chan.make")],
1778 source: None,
1779 allowed_lints: vec![],
1780 };
1781
1782 let use_it = WordDef {
1783 name: "use-it".to_string(),
1784 effect: None,
1785 body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1786 source: None,
1787 allowed_lints: vec![],
1788 };
1789
1790 let program = Program {
1791 words: vec![make_chan, use_it],
1792 includes: vec![],
1793 unions: vec![],
1794 };
1795
1796 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1797 let diagnostics = analyzer.analyze_program(&program);
1798
1799 assert!(
1800 diagnostics.is_empty(),
1801 "Expected no warnings for properly closed channel: {:?}",
1802 diagnostics
1803 );
1804 }
1805
1806 #[test]
1807 fn test_cross_word_chain() {
1808 use crate::ast::Program;
1814
1815 let make_chan = WordDef {
1816 name: "make-chan".to_string(),
1817 effect: None,
1818 body: vec![make_word_call("chan.make")],
1819 source: None,
1820 allowed_lints: vec![],
1821 };
1822
1823 let wrap_chan = WordDef {
1824 name: "wrap-chan".to_string(),
1825 effect: None,
1826 body: vec![make_word_call("make-chan")],
1827 source: None,
1828 allowed_lints: vec![],
1829 };
1830
1831 let leak_chain = WordDef {
1832 name: "leak-chain".to_string(),
1833 effect: None,
1834 body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1835 source: None,
1836 allowed_lints: vec![],
1837 };
1838
1839 let program = Program {
1840 words: vec![make_chan, wrap_chan, leak_chain],
1841 includes: vec![],
1842 unions: vec![],
1843 };
1844
1845 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1846 let diagnostics = analyzer.analyze_program(&program);
1847
1848 assert_eq!(
1850 diagnostics.len(),
1851 1,
1852 "Expected warning for dropped channel through chain: {:?}",
1853 diagnostics
1854 );
1855 }
1856}