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 span: _,
405 } => {
406 state.pop(); let mut then_state = state.clone();
408 let mut else_state = state.clone();
409 self.simulate_statements(then_branch, &mut then_state);
410 if let Some(else_stmts) = else_branch {
411 self.simulate_statements(else_stmts, &mut else_state);
412 }
413 *state = then_state.join(&else_state);
414 }
415
416 Statement::Match { arms, span: _ } => {
417 state.pop();
418 let mut arm_states: Vec<StackState> = Vec::new();
419 for arm in arms {
420 let mut arm_state = state.clone();
421 self.simulate_statements(&arm.body, &mut arm_state);
422 arm_states.push(arm_state);
423 }
424 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
425 *state = joined;
426 }
427 }
428 }
429 }
430
431 fn simulate_word_common<F>(
439 name: &str,
440 span: Option<&Span>,
441 state: &mut StackState,
442 word_info: &HashMap<String, WordResourceInfo>,
443 mut on_resource_dropped: F,
444 ) -> bool
445 where
446 F: FnMut(&TrackedResource),
447 {
448 let line = span.map(|s| s.line).unwrap_or(0);
449
450 match name {
451 "strand.weave" => {
453 state.pop();
454 state.push_resource(ResourceKind::WeaveHandle, line, name);
455 }
456 "chan.make" => {
457 state.push_resource(ResourceKind::Channel, line, name);
458 }
459
460 "strand.weave-cancel" => {
462 if let Some(StackValue::Resource(r)) = state.pop()
463 && r.kind == ResourceKind::WeaveHandle
464 {
465 state.consume_resource(r);
466 }
467 }
468 "chan.close" => {
469 if let Some(StackValue::Resource(r)) = state.pop()
470 && r.kind == ResourceKind::Channel
471 {
472 state.consume_resource(r);
473 }
474 }
475
476 "drop" => {
478 let dropped = state.pop();
479 if let Some(StackValue::Resource(r)) = dropped {
480 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
482 if !already_consumed {
483 on_resource_dropped(&r);
484 }
485 }
486 }
487 "dup" => {
488 if let Some(top) = state.peek().cloned() {
491 state.stack.push(top);
492 }
493 }
494 "swap" => {
495 let a = state.pop();
496 let b = state.pop();
497 if let Some(av) = a {
498 state.stack.push(av);
499 }
500 if let Some(bv) = b {
501 state.stack.push(bv);
502 }
503 }
504 "over" => {
505 if state.depth() >= 2 {
507 let second = state.stack[state.depth() - 2].clone();
508 state.stack.push(second);
509 }
510 }
511 "rot" => {
512 let c = state.pop();
514 let b = state.pop();
515 let a = state.pop();
516 if let Some(bv) = b {
517 state.stack.push(bv);
518 }
519 if let Some(cv) = c {
520 state.stack.push(cv);
521 }
522 if let Some(av) = a {
523 state.stack.push(av);
524 }
525 }
526 "nip" => {
527 let b = state.pop();
529 let a = state.pop();
530 if let Some(StackValue::Resource(r)) = a {
531 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
532 if !already_consumed {
533 on_resource_dropped(&r);
534 }
535 }
536 if let Some(bv) = b {
537 state.stack.push(bv);
538 }
539 }
540 "tuck" => {
541 let b = state.pop();
543 let a = state.pop();
544 if let Some(bv) = b.clone() {
545 state.stack.push(bv);
546 }
547 if let Some(av) = a {
548 state.stack.push(av);
549 }
550 if let Some(bv) = b {
551 state.stack.push(bv);
552 }
553 }
554
555 "strand.spawn" => {
557 state.pop();
558 let resources: Vec<TrackedResource> = state
559 .stack
560 .iter()
561 .filter_map(|v| match v {
562 StackValue::Resource(r) => Some(r.clone()),
563 StackValue::Unknown => None,
564 })
565 .collect();
566 for r in resources {
567 state.consume_resource(r);
568 }
569 state.push_unknown();
570 }
571
572 "map.set" => {
574 let value = state.pop();
576 state.pop(); state.pop(); if let Some(StackValue::Resource(r)) = value {
580 state.consume_resource(r);
581 }
582 state.push_unknown(); }
584
585 "list.push" | "list.prepend" => {
587 let value = state.pop();
589 state.pop(); if let Some(StackValue::Resource(r)) = value {
591 state.consume_resource(r);
592 }
593 state.push_unknown(); }
595
596 _ => {
598 if let Some(info) = word_info.get(name) {
599 for kind in &info.returns {
601 state.push_resource(*kind, line, name);
602 }
603 return true;
604 }
605 return false;
607 }
608 }
609 true
610 }
611
612 fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
614 Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
616 }
617
618 fn analyze_word_with_context(&mut self, word: &WordDef) {
620 let mut state = StackState::new();
621
622 self.analyze_statements_with_context(&word.body, &mut state, word);
623
624 }
626
627 fn analyze_statements_with_context(
629 &mut self,
630 statements: &[Statement],
631 state: &mut StackState,
632 word: &WordDef,
633 ) {
634 for stmt in statements {
635 self.analyze_statement_with_context(stmt, state, word);
636 }
637 }
638
639 fn analyze_statement_with_context(
641 &mut self,
642 stmt: &Statement,
643 state: &mut StackState,
644 word: &WordDef,
645 ) {
646 match stmt {
647 Statement::IntLiteral(_)
648 | Statement::FloatLiteral(_)
649 | Statement::BoolLiteral(_)
650 | Statement::StringLiteral(_)
651 | Statement::Symbol(_) => {
652 state.push_unknown();
653 }
654
655 Statement::WordCall { name, span } => {
656 self.analyze_word_call_with_context(name, span.as_ref(), state, word);
657 }
658
659 Statement::Quotation { .. } => {
660 state.push_unknown();
661 }
662
663 Statement::If {
664 then_branch,
665 else_branch,
666 span: _,
667 } => {
668 state.pop();
669 let mut then_state = state.clone();
670 let mut else_state = state.clone();
671
672 self.analyze_statements_with_context(then_branch, &mut then_state, word);
673 if let Some(else_stmts) = else_branch {
674 self.analyze_statements_with_context(else_stmts, &mut else_state, word);
675 }
676
677 let merge_result = then_state.merge(&else_state);
679 for inconsistent in merge_result.inconsistent {
680 self.emit_branch_inconsistency_warning(&inconsistent, word);
681 }
682
683 *state = then_state.join(&else_state);
684 }
685
686 Statement::Match { arms, span: _ } => {
687 state.pop();
688 let mut arm_states: Vec<StackState> = Vec::new();
689
690 for arm in arms {
691 let mut arm_state = state.clone();
692 self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
693 arm_states.push(arm_state);
694 }
695
696 if arm_states.len() >= 2 {
698 let first = &arm_states[0];
699 for other in &arm_states[1..] {
700 let merge_result = first.merge(other);
701 for inconsistent in merge_result.inconsistent {
702 self.emit_branch_inconsistency_warning(&inconsistent, word);
703 }
704 }
705 }
706
707 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
708 *state = joined;
709 }
710 }
711 }
712 }
713
714 fn analyze_word_call_with_context(
716 &mut self,
717 name: &str,
718 span: Option<&Span>,
719 state: &mut StackState,
720 word: &WordDef,
721 ) {
722 let mut dropped_resources: Vec<TrackedResource> = Vec::new();
724
725 let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
727 dropped_resources.push(r.clone())
728 });
729
730 for r in dropped_resources {
732 self.emit_drop_warning(&r, span, word);
733 }
734
735 if handled {
736 return;
737 }
738
739 match name {
741 "strand.resume" => {
743 let value = state.pop();
744 let handle = state.pop();
745 if let Some(h) = handle {
746 state.stack.push(h);
747 } else {
748 state.push_unknown();
749 }
750 if let Some(v) = value {
751 state.stack.push(v);
752 } else {
753 state.push_unknown();
754 }
755 state.push_unknown();
756 }
757
758 "2dup" => {
759 if state.depth() >= 2 {
760 let b = state.stack[state.depth() - 1].clone();
761 let a = state.stack[state.depth() - 2].clone();
762 state.stack.push(a);
763 state.stack.push(b);
764 } else {
765 state.push_unknown();
766 state.push_unknown();
767 }
768 }
769
770 "3drop" => {
771 for _ in 0..3 {
772 if let Some(StackValue::Resource(r)) = state.pop() {
773 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
774 if !already_consumed {
775 self.emit_drop_warning(&r, span, word);
776 }
777 }
778 }
779 }
780
781 "pick" | "roll" => {
782 state.pop();
783 state.push_unknown();
784 }
785
786 "chan.send" | "chan.receive" => {
787 state.pop();
788 state.pop();
789 state.push_unknown();
790 state.push_unknown();
791 }
792
793 _ => {}
795 }
796 }
797
798 fn emit_drop_warning(
799 &mut self,
800 resource: &TrackedResource,
801 span: Option<&Span>,
802 word: &WordDef,
803 ) {
804 let line = span
805 .map(|s| s.line)
806 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
807 let column = span.map(|s| s.column);
808
809 self.diagnostics.push(LintDiagnostic {
810 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
811 message: format!(
812 "{} from `{}` (line {}) dropped without cleanup - {}",
813 resource.kind.name(),
814 resource.created_by,
815 resource.created_line + 1,
816 resource.kind.cleanup_suggestion()
817 ),
818 severity: Severity::Warning,
819 replacement: String::new(),
820 file: self.file.clone(),
821 line,
822 end_line: None,
823 start_column: column,
824 end_column: column.map(|c| c + 4),
825 word_name: word.name.clone(),
826 start_index: 0,
827 end_index: 0,
828 });
829 }
830
831 fn emit_branch_inconsistency_warning(
832 &mut self,
833 inconsistent: &InconsistentResource,
834 word: &WordDef,
835 ) {
836 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
837 let branch = if inconsistent.consumed_in_else {
838 "else"
839 } else {
840 "then"
841 };
842
843 self.diagnostics.push(LintDiagnostic {
844 id: "resource-branch-inconsistent".to_string(),
845 message: format!(
846 "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
847 inconsistent.resource.kind.name(),
848 inconsistent.resource.created_by,
849 inconsistent.resource.created_line + 1,
850 branch
851 ),
852 severity: Severity::Warning,
853 replacement: String::new(),
854 file: self.file.clone(),
855 line,
856 end_line: None,
857 start_column: None,
858 end_column: None,
859 word_name: word.name.clone(),
860 start_index: 0,
861 end_index: 0,
862 });
863 }
864}
865
866pub struct ResourceAnalyzer {
868 diagnostics: Vec<LintDiagnostic>,
870 file: std::path::PathBuf,
872}
873
874impl ResourceAnalyzer {
875 pub fn new(file: &Path) -> Self {
876 ResourceAnalyzer {
877 diagnostics: Vec::new(),
878 file: file.to_path_buf(),
879 }
880 }
881
882 pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
884 self.diagnostics.clear();
885
886 let mut state = StackState::new();
887
888 self.analyze_statements(&word.body, &mut state, word);
890
891 let _ = state.remaining_resources(); std::mem::take(&mut self.diagnostics)
907 }
908
909 fn analyze_statements(
911 &mut self,
912 statements: &[Statement],
913 state: &mut StackState,
914 word: &WordDef,
915 ) {
916 for stmt in statements {
917 self.analyze_statement(stmt, state, word);
918 }
919 }
920
921 fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
923 match stmt {
924 Statement::IntLiteral(_)
925 | Statement::FloatLiteral(_)
926 | Statement::BoolLiteral(_)
927 | Statement::StringLiteral(_)
928 | Statement::Symbol(_) => {
929 state.push_unknown();
930 }
931
932 Statement::WordCall { name, span } => {
933 self.analyze_word_call(name, span.as_ref(), state, word);
934 }
935
936 Statement::Quotation { body, .. } => {
937 let _ = body; state.push_unknown();
943 }
944
945 Statement::If {
946 then_branch,
947 else_branch,
948 span: _,
949 } => {
950 self.analyze_if(then_branch, else_branch.as_ref(), state, word);
951 }
952
953 Statement::Match { arms, span: _ } => {
954 self.analyze_match(arms, state, word);
955 }
956 }
957 }
958
959 fn analyze_word_call(
961 &mut self,
962 name: &str,
963 span: Option<&Span>,
964 state: &mut StackState,
965 word: &WordDef,
966 ) {
967 let line = span.map(|s| s.line).unwrap_or(0);
968
969 match name {
970 "strand.weave" => {
972 state.pop(); state.push_resource(ResourceKind::WeaveHandle, line, name);
975 }
976
977 "chan.make" => {
978 state.push_resource(ResourceKind::Channel, line, name);
980 }
981
982 "strand.weave-cancel" => {
984 if let Some(StackValue::Resource(r)) = state.pop()
986 && r.kind == ResourceKind::WeaveHandle
987 {
988 state.consume_resource(r);
989 }
990 }
991
992 "chan.close" => {
993 if let Some(StackValue::Resource(r)) = state.pop()
995 && r.kind == ResourceKind::Channel
996 {
997 state.consume_resource(r);
998 }
999 }
1000
1001 "strand.resume" => {
1006 let value = state.pop(); let handle = state.pop(); if let Some(h) = handle {
1012 state.stack.push(h);
1013 } else {
1014 state.push_unknown();
1015 }
1016 if let Some(v) = value {
1017 state.stack.push(v);
1018 } else {
1019 state.push_unknown();
1020 }
1021 state.push_unknown(); }
1023
1024 "drop" => {
1026 let dropped = state.pop();
1027 if let Some(StackValue::Resource(r)) = dropped {
1030 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1031 if !already_consumed {
1032 self.emit_drop_warning(&r, span, word);
1033 }
1034 }
1035 }
1036
1037 "dup" => {
1038 if let Some(top) = state.peek().cloned() {
1039 state.stack.push(top);
1040 } else {
1041 state.push_unknown();
1042 }
1043 }
1044
1045 "swap" => {
1046 let a = state.pop();
1047 let b = state.pop();
1048 if let Some(av) = a {
1049 state.stack.push(av);
1050 }
1051 if let Some(bv) = b {
1052 state.stack.push(bv);
1053 }
1054 }
1055
1056 "over" => {
1057 if state.depth() >= 2 {
1059 let second = state.stack[state.depth() - 2].clone();
1060 state.stack.push(second);
1061 } else {
1062 state.push_unknown();
1063 }
1064 }
1065
1066 "rot" => {
1067 let c = state.pop();
1069 let b = state.pop();
1070 let a = state.pop();
1071 if let Some(bv) = b {
1072 state.stack.push(bv);
1073 }
1074 if let Some(cv) = c {
1075 state.stack.push(cv);
1076 }
1077 if let Some(av) = a {
1078 state.stack.push(av);
1079 }
1080 }
1081
1082 "nip" => {
1083 let b = state.pop();
1085 let a = state.pop();
1086 if let Some(StackValue::Resource(r)) = a {
1087 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1088 if !already_consumed {
1089 self.emit_drop_warning(&r, span, word);
1090 }
1091 }
1092 if let Some(bv) = b {
1093 state.stack.push(bv);
1094 }
1095 }
1096
1097 "tuck" => {
1098 let b = state.pop();
1100 let a = state.pop();
1101 if let Some(bv) = b.clone() {
1102 state.stack.push(bv);
1103 }
1104 if let Some(av) = a {
1105 state.stack.push(av);
1106 }
1107 if let Some(bv) = b {
1108 state.stack.push(bv);
1109 }
1110 }
1111
1112 "2dup" => {
1113 if state.depth() >= 2 {
1115 let b = state.stack[state.depth() - 1].clone();
1116 let a = state.stack[state.depth() - 2].clone();
1117 state.stack.push(a);
1118 state.stack.push(b);
1119 } else {
1120 state.push_unknown();
1121 state.push_unknown();
1122 }
1123 }
1124
1125 "3drop" => {
1126 for _ in 0..3 {
1127 if let Some(StackValue::Resource(r)) = state.pop() {
1128 let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1129 if !already_consumed {
1130 self.emit_drop_warning(&r, span, word);
1131 }
1132 }
1133 }
1134 }
1135
1136 "pick" => {
1137 state.pop(); state.push_unknown();
1141 }
1142
1143 "roll" => {
1144 state.pop(); state.push_unknown();
1147 }
1148
1149 "chan.send" | "chan.receive" => {
1151 state.pop();
1155 state.pop();
1156 state.push_unknown();
1157 state.push_unknown();
1158 }
1159
1160 "strand.spawn" => {
1163 state.pop(); let resources_on_stack: Vec<TrackedResource> = state
1168 .stack
1169 .iter()
1170 .filter_map(|v| match v {
1171 StackValue::Resource(r) => Some(r.clone()),
1172 StackValue::Unknown => None,
1173 })
1174 .collect();
1175 for r in resources_on_stack {
1176 state.consume_resource(r);
1177 }
1178 state.push_unknown(); }
1180
1181 _ => {
1185 }
1189 }
1190 }
1191
1192 fn analyze_if(
1194 &mut self,
1195 then_branch: &[Statement],
1196 else_branch: Option<&Vec<Statement>>,
1197 state: &mut StackState,
1198 word: &WordDef,
1199 ) {
1200 state.pop();
1202
1203 let mut then_state = state.clone();
1205 let mut else_state = state.clone();
1206
1207 self.analyze_statements(then_branch, &mut then_state, word);
1209
1210 if let Some(else_stmts) = else_branch {
1212 self.analyze_statements(else_stmts, &mut else_state, word);
1213 }
1214
1215 let merge_result = then_state.merge(&else_state);
1217 for inconsistent in merge_result.inconsistent {
1218 self.emit_branch_inconsistency_warning(&inconsistent, word);
1219 }
1220
1221 *state = then_state.join(&else_state);
1225 }
1226
1227 fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
1229 state.pop();
1231
1232 if arms.is_empty() {
1233 return;
1234 }
1235
1236 let mut arm_states: Vec<StackState> = Vec::new();
1238
1239 for arm in arms {
1240 let mut arm_state = state.clone();
1241
1242 match &arm.pattern {
1245 crate::ast::Pattern::Variant(_) => {
1246 }
1249 crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
1250 for _ in bindings {
1252 arm_state.push_unknown();
1253 }
1254 }
1255 }
1256
1257 self.analyze_statements(&arm.body, &mut arm_state, word);
1258 arm_states.push(arm_state);
1259 }
1260
1261 if arm_states.len() >= 2 {
1263 let first = &arm_states[0];
1264 for other in &arm_states[1..] {
1265 let merge_result = first.merge(other);
1266 for inconsistent in merge_result.inconsistent {
1267 self.emit_branch_inconsistency_warning(&inconsistent, word);
1268 }
1269 }
1270 }
1271
1272 if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
1275 *state = first;
1276 }
1277 }
1278
1279 fn emit_drop_warning(
1281 &mut self,
1282 resource: &TrackedResource,
1283 span: Option<&Span>,
1284 word: &WordDef,
1285 ) {
1286 let line = span
1287 .map(|s| s.line)
1288 .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
1289 let column = span.map(|s| s.column);
1290
1291 self.diagnostics.push(LintDiagnostic {
1292 id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
1293 message: format!(
1294 "{} created at line {} dropped without cleanup - {}",
1295 resource.kind.name(),
1296 resource.created_line + 1,
1297 resource.kind.cleanup_suggestion()
1298 ),
1299 severity: Severity::Warning,
1300 replacement: String::new(),
1301 file: self.file.clone(),
1302 line,
1303 end_line: None,
1304 start_column: column,
1305 end_column: column.map(|c| c + 4), word_name: word.name.clone(),
1307 start_index: 0,
1308 end_index: 0,
1309 });
1310 }
1311
1312 fn emit_branch_inconsistency_warning(
1314 &mut self,
1315 inconsistent: &InconsistentResource,
1316 word: &WordDef,
1317 ) {
1318 let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
1319 let branch = if inconsistent.consumed_in_else {
1320 "else"
1321 } else {
1322 "then"
1323 };
1324
1325 self.diagnostics.push(LintDiagnostic {
1326 id: "resource-branch-inconsistent".to_string(),
1327 message: format!(
1328 "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
1329 inconsistent.resource.kind.name(),
1330 inconsistent.resource.created_line + 1,
1331 branch
1332 ),
1333 severity: Severity::Warning,
1334 replacement: String::new(),
1335 file: self.file.clone(),
1336 line,
1337 end_line: None,
1338 start_column: None,
1339 end_column: None,
1340 word_name: word.name.clone(),
1341 start_index: 0,
1342 end_index: 0,
1343 });
1344 }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350 use crate::ast::{Statement, WordDef};
1351
1352 fn make_word_call(name: &str) -> Statement {
1353 Statement::WordCall {
1354 name: name.to_string(),
1355 span: Some(Span::new(0, 0, name.len())),
1356 }
1357 }
1358
1359 #[test]
1360 fn test_immediate_weave_drop() {
1361 let word = WordDef {
1363 name: "bad".to_string(),
1364 effect: None,
1365 body: vec![
1366 Statement::Quotation {
1367 span: None,
1368 id: 0,
1369 body: vec![make_word_call("gen")],
1370 },
1371 make_word_call("strand.weave"),
1372 make_word_call("drop"),
1373 ],
1374 source: None,
1375 allowed_lints: vec![],
1376 };
1377
1378 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1379 let diagnostics = analyzer.analyze_word(&word);
1380
1381 assert_eq!(diagnostics.len(), 1);
1382 assert!(diagnostics[0].id.contains("weavehandle"));
1383 assert!(diagnostics[0].message.contains("dropped without cleanup"));
1384 }
1385
1386 #[test]
1387 fn test_weave_properly_cancelled() {
1388 let word = WordDef {
1390 name: "good".to_string(),
1391 effect: None,
1392 body: vec![
1393 Statement::Quotation {
1394 span: None,
1395 id: 0,
1396 body: vec![make_word_call("gen")],
1397 },
1398 make_word_call("strand.weave"),
1399 make_word_call("strand.weave-cancel"),
1400 ],
1401 source: None,
1402 allowed_lints: vec![],
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 span: None,
1434 },
1435 ],
1436 source: None,
1437 allowed_lints: vec![],
1438 };
1439
1440 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1441 let diagnostics = analyzer.analyze_word(&word);
1442
1443 assert!(!diagnostics.is_empty());
1445 }
1446
1447 #[test]
1448 fn test_both_branches_cancel() {
1449 let word = WordDef {
1453 name: "good".to_string(),
1454 effect: None,
1455 body: vec![
1456 Statement::Quotation {
1457 span: None,
1458 id: 0,
1459 body: vec![make_word_call("gen")],
1460 },
1461 make_word_call("strand.weave"),
1462 Statement::BoolLiteral(true),
1463 Statement::If {
1464 then_branch: vec![make_word_call("strand.weave-cancel")],
1465 else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
1466 span: None,
1467 },
1468 ],
1469 source: None,
1470 allowed_lints: vec![],
1471 };
1472
1473 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1474 let diagnostics = analyzer.analyze_word(&word);
1475
1476 assert!(
1477 diagnostics.is_empty(),
1478 "Expected no warnings when both branches cancel"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_channel_leak() {
1484 let word = WordDef {
1486 name: "bad".to_string(),
1487 effect: None,
1488 body: vec![make_word_call("chan.make"), make_word_call("drop")],
1489 source: None,
1490 allowed_lints: vec![],
1491 };
1492
1493 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1494 let diagnostics = analyzer.analyze_word(&word);
1495
1496 assert_eq!(diagnostics.len(), 1);
1497 assert!(diagnostics[0].id.contains("channel"));
1498 }
1499
1500 #[test]
1501 fn test_channel_properly_closed() {
1502 let word = WordDef {
1504 name: "good".to_string(),
1505 effect: None,
1506 body: vec![make_word_call("chan.make"), make_word_call("chan.close")],
1507 source: None,
1508 allowed_lints: vec![],
1509 };
1510
1511 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1512 let diagnostics = analyzer.analyze_word(&word);
1513
1514 assert!(
1515 diagnostics.is_empty(),
1516 "Expected no warnings for properly closed channel"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_swap_resource_tracking() {
1522 let word = WordDef {
1526 name: "test".to_string(),
1527 effect: None,
1528 body: vec![
1529 make_word_call("chan.make"),
1530 Statement::IntLiteral(1),
1531 make_word_call("swap"),
1532 make_word_call("drop"), make_word_call("drop"), ],
1535 source: None,
1536 allowed_lints: vec![],
1537 };
1538
1539 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1540 let diagnostics = analyzer.analyze_word(&word);
1541
1542 assert_eq!(
1543 diagnostics.len(),
1544 1,
1545 "Expected warning for dropped channel: {:?}",
1546 diagnostics
1547 );
1548 assert!(diagnostics[0].id.contains("channel"));
1549 }
1550
1551 #[test]
1552 fn test_over_resource_tracking() {
1553 let word = WordDef {
1559 name: "test".to_string(),
1560 effect: None,
1561 body: vec![
1562 make_word_call("chan.make"),
1563 Statement::IntLiteral(1),
1564 make_word_call("over"),
1565 make_word_call("drop"), make_word_call("drop"), make_word_call("drop"), ],
1569 source: None,
1570 allowed_lints: vec![],
1571 };
1572
1573 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1574 let diagnostics = analyzer.analyze_word(&word);
1575
1576 assert_eq!(
1578 diagnostics.len(),
1579 2,
1580 "Expected 2 warnings for dropped channels: {:?}",
1581 diagnostics
1582 );
1583 }
1584
1585 #[test]
1586 fn test_channel_transferred_via_spawn() {
1587 let word = WordDef {
1595 name: "accept-loop".to_string(),
1596 effect: None,
1597 body: vec![
1598 make_word_call("chan.make"),
1599 make_word_call("dup"),
1600 Statement::Quotation {
1601 span: None,
1602 id: 0,
1603 body: vec![make_word_call("worker")],
1604 },
1605 make_word_call("strand.spawn"),
1606 make_word_call("drop"),
1607 make_word_call("drop"),
1608 make_word_call("chan.send"),
1609 ],
1610 source: None,
1611 allowed_lints: vec![],
1612 };
1613
1614 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1615 let diagnostics = analyzer.analyze_word(&word);
1616
1617 assert!(
1618 diagnostics.is_empty(),
1619 "Expected no warnings when channel is transferred via strand.spawn: {:?}",
1620 diagnostics
1621 );
1622 }
1623
1624 #[test]
1625 fn test_else_branch_only_leak() {
1626 let word = WordDef {
1632 name: "test".to_string(),
1633 effect: None,
1634 body: vec![
1635 make_word_call("chan.make"),
1636 Statement::BoolLiteral(true),
1637 Statement::If {
1638 then_branch: vec![make_word_call("chan.close")],
1639 else_branch: Some(vec![make_word_call("drop")]),
1640 span: None,
1641 },
1642 ],
1643 source: None,
1644 allowed_lints: vec![],
1645 };
1646
1647 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1648 let diagnostics = analyzer.analyze_word(&word);
1649
1650 assert!(
1652 !diagnostics.is_empty(),
1653 "Expected warnings for else-branch leak: {:?}",
1654 diagnostics
1655 );
1656 }
1657
1658 #[test]
1659 fn test_branch_join_both_consume() {
1660 let word = WordDef {
1665 name: "test".to_string(),
1666 effect: None,
1667 body: vec![
1668 make_word_call("chan.make"),
1669 Statement::BoolLiteral(true),
1670 Statement::If {
1671 then_branch: vec![make_word_call("chan.close")],
1672 else_branch: Some(vec![make_word_call("chan.close")]),
1673 span: None,
1674 },
1675 ],
1676 source: None,
1677 allowed_lints: vec![],
1678 };
1679
1680 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1681 let diagnostics = analyzer.analyze_word(&word);
1682
1683 assert!(
1684 diagnostics.is_empty(),
1685 "Expected no warnings when both branches consume: {:?}",
1686 diagnostics
1687 );
1688 }
1689
1690 #[test]
1691 fn test_branch_join_neither_consume() {
1692 let word = WordDef {
1697 name: "test".to_string(),
1698 effect: None,
1699 body: vec![
1700 make_word_call("chan.make"),
1701 Statement::BoolLiteral(true),
1702 Statement::If {
1703 then_branch: vec![],
1704 else_branch: Some(vec![]),
1705 span: None,
1706 },
1707 make_word_call("drop"), ],
1709 source: None,
1710 allowed_lints: vec![],
1711 };
1712
1713 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1714 let diagnostics = analyzer.analyze_word(&word);
1715
1716 assert_eq!(
1717 diagnostics.len(),
1718 1,
1719 "Expected warning for dropped channel: {:?}",
1720 diagnostics
1721 );
1722 assert!(diagnostics[0].id.contains("channel"));
1723 }
1724
1725 #[test]
1730 fn test_cross_word_resource_tracking() {
1731 use crate::ast::Program;
1738
1739 let make_chan = WordDef {
1740 name: "make-chan".to_string(),
1741 effect: None,
1742 body: vec![make_word_call("chan.make")],
1743 source: None,
1744 allowed_lints: vec![],
1745 };
1746
1747 let leak_it = WordDef {
1748 name: "leak-it".to_string(),
1749 effect: None,
1750 body: vec![make_word_call("make-chan"), make_word_call("drop")],
1751 source: None,
1752 allowed_lints: vec![],
1753 };
1754
1755 let program = Program {
1756 words: vec![make_chan, leak_it],
1757 includes: vec![],
1758 unions: vec![],
1759 };
1760
1761 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1762 let diagnostics = analyzer.analyze_program(&program);
1763
1764 assert_eq!(
1765 diagnostics.len(),
1766 1,
1767 "Expected warning for dropped channel from make-chan: {:?}",
1768 diagnostics
1769 );
1770 assert!(diagnostics[0].id.contains("channel"));
1771 assert!(diagnostics[0].message.contains("make-chan"));
1772 }
1773
1774 #[test]
1775 fn test_cross_word_proper_cleanup() {
1776 use crate::ast::Program;
1781
1782 let make_chan = WordDef {
1783 name: "make-chan".to_string(),
1784 effect: None,
1785 body: vec![make_word_call("chan.make")],
1786 source: None,
1787 allowed_lints: vec![],
1788 };
1789
1790 let use_it = WordDef {
1791 name: "use-it".to_string(),
1792 effect: None,
1793 body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1794 source: None,
1795 allowed_lints: vec![],
1796 };
1797
1798 let program = Program {
1799 words: vec![make_chan, use_it],
1800 includes: vec![],
1801 unions: vec![],
1802 };
1803
1804 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1805 let diagnostics = analyzer.analyze_program(&program);
1806
1807 assert!(
1808 diagnostics.is_empty(),
1809 "Expected no warnings for properly closed channel: {:?}",
1810 diagnostics
1811 );
1812 }
1813
1814 #[test]
1815 fn test_cross_word_chain() {
1816 use crate::ast::Program;
1822
1823 let make_chan = WordDef {
1824 name: "make-chan".to_string(),
1825 effect: None,
1826 body: vec![make_word_call("chan.make")],
1827 source: None,
1828 allowed_lints: vec![],
1829 };
1830
1831 let wrap_chan = WordDef {
1832 name: "wrap-chan".to_string(),
1833 effect: None,
1834 body: vec![make_word_call("make-chan")],
1835 source: None,
1836 allowed_lints: vec![],
1837 };
1838
1839 let leak_chain = WordDef {
1840 name: "leak-chain".to_string(),
1841 effect: None,
1842 body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1843 source: None,
1844 allowed_lints: vec![],
1845 };
1846
1847 let program = Program {
1848 words: vec![make_chan, wrap_chan, leak_chain],
1849 includes: vec![],
1850 unions: vec![],
1851 };
1852
1853 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1854 let diagnostics = analyzer.analyze_program(&program);
1855
1856 assert_eq!(
1858 diagnostics.len(),
1859 1,
1860 "Expected warning for dropped channel through chain: {:?}",
1861 diagnostics
1862 );
1863 }
1864}