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 allowed_lints: vec![],
1378 };
1379
1380 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1381 let diagnostics = analyzer.analyze_word(&word);
1382
1383 assert_eq!(diagnostics.len(), 1);
1384 assert!(diagnostics[0].id.contains("weavehandle"));
1385 assert!(diagnostics[0].message.contains("dropped without cleanup"));
1386 }
1387
1388 #[test]
1389 fn test_weave_properly_cancelled() {
1390 let word = WordDef {
1392 name: "good".to_string(),
1393 effect: None,
1394 body: vec![
1395 Statement::Quotation {
1396 span: None,
1397 id: 0,
1398 body: vec![make_word_call("gen")],
1399 },
1400 make_word_call("strand.weave"),
1401 make_word_call("strand.weave-cancel"),
1402 ],
1403 source: None,
1404 allowed_lints: vec![],
1405 };
1406
1407 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1408 let diagnostics = analyzer.analyze_word(&word);
1409
1410 assert!(
1411 diagnostics.is_empty(),
1412 "Expected no warnings for properly cancelled weave"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_branch_inconsistent_handling() {
1418 let word = WordDef {
1422 name: "bad".to_string(),
1423 effect: None,
1424 body: vec![
1425 Statement::Quotation {
1426 span: None,
1427 id: 0,
1428 body: vec![make_word_call("gen")],
1429 },
1430 make_word_call("strand.weave"),
1431 Statement::BoolLiteral(true),
1432 Statement::If {
1433 then_branch: vec![make_word_call("strand.weave-cancel")],
1434 else_branch: Some(vec![make_word_call("drop")]),
1435 },
1436 ],
1437 source: None,
1438 allowed_lints: vec![],
1439 };
1440
1441 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1442 let diagnostics = analyzer.analyze_word(&word);
1443
1444 assert!(!diagnostics.is_empty());
1446 }
1447
1448 #[test]
1449 fn test_both_branches_cancel() {
1450 let word = WordDef {
1454 name: "good".to_string(),
1455 effect: None,
1456 body: vec![
1457 Statement::Quotation {
1458 span: None,
1459 id: 0,
1460 body: vec![make_word_call("gen")],
1461 },
1462 make_word_call("strand.weave"),
1463 Statement::BoolLiteral(true),
1464 Statement::If {
1465 then_branch: vec![make_word_call("strand.weave-cancel")],
1466 else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
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 },
1641 ],
1642 source: None,
1643 allowed_lints: vec![],
1644 };
1645
1646 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1647 let diagnostics = analyzer.analyze_word(&word);
1648
1649 assert!(
1651 !diagnostics.is_empty(),
1652 "Expected warnings for else-branch leak: {:?}",
1653 diagnostics
1654 );
1655 }
1656
1657 #[test]
1658 fn test_branch_join_both_consume() {
1659 let word = WordDef {
1664 name: "test".to_string(),
1665 effect: None,
1666 body: vec![
1667 make_word_call("chan.make"),
1668 Statement::BoolLiteral(true),
1669 Statement::If {
1670 then_branch: vec![make_word_call("chan.close")],
1671 else_branch: Some(vec![make_word_call("chan.close")]),
1672 },
1673 ],
1674 source: None,
1675 allowed_lints: vec![],
1676 };
1677
1678 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1679 let diagnostics = analyzer.analyze_word(&word);
1680
1681 assert!(
1682 diagnostics.is_empty(),
1683 "Expected no warnings when both branches consume: {:?}",
1684 diagnostics
1685 );
1686 }
1687
1688 #[test]
1689 fn test_branch_join_neither_consume() {
1690 let word = WordDef {
1695 name: "test".to_string(),
1696 effect: None,
1697 body: vec![
1698 make_word_call("chan.make"),
1699 Statement::BoolLiteral(true),
1700 Statement::If {
1701 then_branch: vec![],
1702 else_branch: Some(vec![]),
1703 },
1704 make_word_call("drop"), ],
1706 source: None,
1707 allowed_lints: vec![],
1708 };
1709
1710 let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1711 let diagnostics = analyzer.analyze_word(&word);
1712
1713 assert_eq!(
1714 diagnostics.len(),
1715 1,
1716 "Expected warning for dropped channel: {:?}",
1717 diagnostics
1718 );
1719 assert!(diagnostics[0].id.contains("channel"));
1720 }
1721
1722 #[test]
1727 fn test_cross_word_resource_tracking() {
1728 use crate::ast::Program;
1735
1736 let make_chan = WordDef {
1737 name: "make-chan".to_string(),
1738 effect: None,
1739 body: vec![make_word_call("chan.make")],
1740 source: None,
1741 allowed_lints: vec![],
1742 };
1743
1744 let leak_it = WordDef {
1745 name: "leak-it".to_string(),
1746 effect: None,
1747 body: vec![make_word_call("make-chan"), make_word_call("drop")],
1748 source: None,
1749 allowed_lints: vec![],
1750 };
1751
1752 let program = Program {
1753 words: vec![make_chan, leak_it],
1754 includes: vec![],
1755 unions: vec![],
1756 };
1757
1758 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1759 let diagnostics = analyzer.analyze_program(&program);
1760
1761 assert_eq!(
1762 diagnostics.len(),
1763 1,
1764 "Expected warning for dropped channel from make-chan: {:?}",
1765 diagnostics
1766 );
1767 assert!(diagnostics[0].id.contains("channel"));
1768 assert!(diagnostics[0].message.contains("make-chan"));
1769 }
1770
1771 #[test]
1772 fn test_cross_word_proper_cleanup() {
1773 use crate::ast::Program;
1778
1779 let make_chan = WordDef {
1780 name: "make-chan".to_string(),
1781 effect: None,
1782 body: vec![make_word_call("chan.make")],
1783 source: None,
1784 allowed_lints: vec![],
1785 };
1786
1787 let use_it = WordDef {
1788 name: "use-it".to_string(),
1789 effect: None,
1790 body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1791 source: None,
1792 allowed_lints: vec![],
1793 };
1794
1795 let program = Program {
1796 words: vec![make_chan, use_it],
1797 includes: vec![],
1798 unions: vec![],
1799 };
1800
1801 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1802 let diagnostics = analyzer.analyze_program(&program);
1803
1804 assert!(
1805 diagnostics.is_empty(),
1806 "Expected no warnings for properly closed channel: {:?}",
1807 diagnostics
1808 );
1809 }
1810
1811 #[test]
1812 fn test_cross_word_chain() {
1813 use crate::ast::Program;
1819
1820 let make_chan = WordDef {
1821 name: "make-chan".to_string(),
1822 effect: None,
1823 body: vec![make_word_call("chan.make")],
1824 source: None,
1825 allowed_lints: vec![],
1826 };
1827
1828 let wrap_chan = WordDef {
1829 name: "wrap-chan".to_string(),
1830 effect: None,
1831 body: vec![make_word_call("make-chan")],
1832 source: None,
1833 allowed_lints: vec![],
1834 };
1835
1836 let leak_chain = WordDef {
1837 name: "leak-chain".to_string(),
1838 effect: None,
1839 body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1840 source: None,
1841 allowed_lints: vec![],
1842 };
1843
1844 let program = Program {
1845 words: vec![make_chan, wrap_chan, leak_chain],
1846 includes: vec![],
1847 unions: vec![],
1848 };
1849
1850 let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1851 let diagnostics = analyzer.analyze_program(&program);
1852
1853 assert_eq!(
1855 diagnostics.len(),
1856 1,
1857 "Expected warning for dropped channel through chain: {:?}",
1858 diagnostics
1859 );
1860 }
1861}