1use crate::ast::{Program, Span, Statement, WordDef};
28use crate::lint::{LintDiagnostic, Severity};
29use std::path::{Path, PathBuf};
30
31#[derive(Debug, Clone)]
33struct ErrorFlag {
34 created_line: usize,
36 operation: String,
38 description: String,
40}
41
42#[derive(Debug, Clone)]
44enum StackVal {
45 Flag(ErrorFlag),
47 Other,
49}
50
51#[derive(Debug, Clone)]
53struct FlagStack {
54 stack: Vec<StackVal>,
55 aux: Vec<StackVal>,
56}
57
58impl FlagStack {
59 fn new() -> Self {
60 FlagStack {
61 stack: Vec::new(),
62 aux: Vec::new(),
63 }
64 }
65
66 fn push_other(&mut self) {
67 self.stack.push(StackVal::Other);
68 }
69
70 fn push_flag(&mut self, line: usize, operation: &str, description: &str) {
71 let flag = ErrorFlag {
72 created_line: line,
73 operation: operation.to_string(),
74 description: description.to_string(),
75 };
76 self.stack.push(StackVal::Flag(flag));
77 }
78
79 fn pop(&mut self) -> Option<StackVal> {
80 self.stack.pop()
81 }
82
83 fn depth(&self) -> usize {
84 self.stack.len()
85 }
86
87 fn join(&self, other: &FlagStack) -> FlagStack {
89 let len = self.stack.len().max(other.stack.len());
91 let mut joined = Vec::with_capacity(len);
92
93 for i in 0..len {
94 let a = self.stack.get(i);
95 let b = other.stack.get(i);
96 let val = match (a, b) {
98 (Some(StackVal::Flag(f)), _) => StackVal::Flag(f.clone()),
99 (_, Some(StackVal::Flag(f))) => StackVal::Flag(f.clone()),
100 _ => StackVal::Other,
101 };
102 joined.push(val);
103 }
104
105 let aux_len = self.aux.len().max(other.aux.len());
107 let mut joined_aux = Vec::with_capacity(aux_len);
108 for i in 0..aux_len {
109 let a = self.aux.get(i);
110 let b = other.aux.get(i);
111 let val = match (a, b) {
112 (Some(StackVal::Flag(f)), _) => StackVal::Flag(f.clone()),
113 (_, Some(StackVal::Flag(f))) => StackVal::Flag(f.clone()),
114 _ => StackVal::Other,
115 };
116 joined_aux.push(val);
117 }
118
119 FlagStack {
120 stack: joined,
121 aux: joined_aux,
122 }
123 }
124}
125
126struct FallibleOpInfo {
128 inputs: usize,
130 values_before_bool: usize,
132 description: &'static str,
134}
135
136fn fallible_op_info(name: &str) -> Option<FallibleOpInfo> {
139 let (inputs, values_before_bool, description) = match name {
140 "i./" | "i.divide" => (2, 1, "division by zero"),
142 "i.%" | "i.modulo" => (2, 1, "modulo by zero"),
143
144 "file.slurp" => (1, 1, "file read failure"),
146 "file.spit" => (2, 0, "file write failure"),
147 "file.append" => (2, 0, "file append failure"),
148 "file.delete" => (1, 0, "file delete failure"),
149 "file.size" => (1, 1, "file size failure"),
150 "dir.make" => (1, 0, "directory creation failure"),
151 "dir.delete" => (1, 0, "directory delete failure"),
152 "dir.list" => (1, 1, "directory list failure"),
153
154 "io.read-line" => (0, 1, "read failure"),
156
157 "string->int" => (1, 1, "parse failure"),
159 "string->float" => (1, 1, "parse failure"),
160
161 "chan.send" => (2, 0, "send failure"),
163 "chan.receive" => (1, 1, "receive failure"),
164
165 "map.get" => (2, 1, "key not found"),
167 "list.get" => (2, 1, "index out of bounds"),
168 "list.set" => (3, 1, "index out of bounds"),
169
170 "tcp.listen" => (1, 1, "listen failure"),
172 "tcp.accept" => (1, 1, "accept failure"),
173 "tcp.read" => (1, 1, "read failure"),
174 "tcp.write" => (2, 0, "write failure"),
175 "tcp.close" => (1, 0, "close failure"),
176
177 "os.getenv" => (1, 1, "env var not set"),
179 "os.home-dir" => (0, 1, "home dir not available"),
180 "os.current-dir" => (0, 1, "current dir not available"),
181 "os.path-parent" => (1, 1, "no parent path"),
182 "os.path-filename" => (1, 1, "no filename"),
183
184 "regex.find" => (2, 1, "no match or invalid regex"),
186 "regex.find-all" => (2, 1, "invalid regex"),
187 "regex.replace" => (3, 1, "invalid regex"),
188 "regex.replace-all" => (3, 1, "invalid regex"),
189 "regex.captures" => (2, 1, "invalid regex"),
190 "regex.split" => (2, 1, "invalid regex"),
191
192 "encoding.base64-decode" => (1, 1, "invalid base64"),
194 "encoding.base64url-decode" => (1, 1, "invalid base64url"),
195 "encoding.hex-decode" => (1, 1, "invalid hex"),
196
197 "crypto.aes-gcm-encrypt" => (2, 1, "encryption failure"),
199 "crypto.aes-gcm-decrypt" => (2, 1, "decryption failure"),
200 "crypto.pbkdf2-sha256" => (3, 1, "key derivation failure"),
201 "crypto.ed25519-sign" => (2, 1, "signing failure"),
202
203 "compress.gzip" => (1, 1, "compression failure"),
205 "compress.gzip-level" => (2, 1, "compression failure"),
206 "compress.gunzip" => (1, 1, "decompression failure"),
207 "compress.zstd" => (1, 1, "compression failure"),
208 "compress.zstd-level" => (2, 1, "compression failure"),
209 "compress.unzstd" => (1, 1, "decompression failure"),
210
211 _ => return None,
212 };
213 Some(FallibleOpInfo {
214 inputs,
215 values_before_bool,
216 description,
217 })
218}
219
220fn is_checking_consumer(name: &str) -> bool {
222 name == "cond"
225}
226
227pub struct ErrorFlagAnalyzer {
229 file: PathBuf,
230 diagnostics: Vec<LintDiagnostic>,
231}
232
233impl ErrorFlagAnalyzer {
234 pub fn new(file: &Path) -> Self {
235 ErrorFlagAnalyzer {
236 file: file.to_path_buf(),
237 diagnostics: Vec::new(),
238 }
239 }
240
241 pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
242 let mut all_diagnostics = Vec::new();
243 for word in &program.words {
244 if word
246 .allowed_lints
247 .iter()
248 .any(|l| l == "unchecked-error-flag")
249 {
250 continue;
251 }
252 let diags = self.analyze_word(word);
253 all_diagnostics.extend(diags);
254 }
255 all_diagnostics
256 }
257
258 fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
259 self.diagnostics.clear();
260 let mut state = FlagStack::new();
261 self.analyze_statements(&word.body, &mut state, word);
262 std::mem::take(&mut self.diagnostics)
264 }
265
266 fn analyze_statements(
267 &mut self,
268 statements: &[Statement],
269 state: &mut FlagStack,
270 word: &WordDef,
271 ) {
272 for stmt in statements {
273 self.analyze_statement(stmt, state, word);
274 }
275 }
276
277 fn analyze_statement(&mut self, stmt: &Statement, state: &mut FlagStack, word: &WordDef) {
278 match stmt {
279 Statement::IntLiteral(_)
280 | Statement::FloatLiteral(_)
281 | Statement::BoolLiteral(_)
282 | Statement::StringLiteral(_)
283 | Statement::Symbol(_) => {
284 state.push_other();
285 }
286
287 Statement::Quotation { .. } => {
288 state.push_other();
289 }
290
291 Statement::WordCall { name, span } => {
292 self.analyze_word_call(name, span.as_ref(), state, word);
293 }
294
295 Statement::If {
296 then_branch,
297 else_branch,
298 span: _,
299 } => {
300 state.pop();
302
303 let mut then_state = state.clone();
304 let mut else_state = state.clone();
305 self.analyze_statements(then_branch, &mut then_state, word);
306 if let Some(else_stmts) = else_branch {
307 self.analyze_statements(else_stmts, &mut else_state, word);
308 }
309 *state = then_state.join(&else_state);
310 }
311
312 Statement::Match { arms, span: _ } => {
313 state.pop(); let mut arm_states: Vec<FlagStack> = Vec::new();
315 for arm in arms {
316 let mut arm_state = state.clone();
317 match &arm.pattern {
319 crate::ast::Pattern::Variant(_) => {
320 }
323 crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
324 for _binding in bindings {
325 arm_state.push_other();
326 }
327 }
328 }
329 self.analyze_statements(&arm.body, &mut arm_state, word);
330 arm_states.push(arm_state);
331 }
332 if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
333 *state = joined;
334 }
335 }
336 }
337 }
338
339 fn analyze_word_call(
340 &mut self,
341 name: &str,
342 span: Option<&Span>,
343 state: &mut FlagStack,
344 word: &WordDef,
345 ) {
346 let line = span.map(|s| s.line).unwrap_or(0);
347
348 if let Some(info) = fallible_op_info(name) {
350 for _ in 0..info.inputs {
352 state.pop();
353 }
354 for _ in 0..info.values_before_bool {
356 state.push_other();
357 }
358 state.push_flag(line, name, info.description);
359 return;
360 }
361
362 if is_checking_consumer(name) {
364 state.pop(); return;
371 }
372
373 match name {
375 "drop" => {
376 if let Some(StackVal::Flag(flag)) = state.pop() {
377 self.emit_warning(&flag, line, word);
378 }
379 }
380 "nip" => {
381 let top = state.pop();
383 if let Some(StackVal::Flag(flag)) = state.pop() {
384 self.emit_warning(&flag, line, word);
385 }
386 if let Some(v) = top {
387 state.stack.push(v);
388 }
389 }
390 "3drop" => {
391 for _ in 0..3 {
392 if let Some(StackVal::Flag(flag)) = state.pop() {
393 self.emit_warning(&flag, line, word);
394 }
395 }
396 }
397 "2drop" => {
398 for _ in 0..2 {
399 if let Some(StackVal::Flag(flag)) = state.pop() {
400 self.emit_warning(&flag, line, word);
401 }
402 }
403 }
404 "dup" => {
405 if let Some(top) = state.stack.last().cloned() {
406 state.stack.push(top);
407 }
408 }
409 "swap" => {
410 let a = state.pop();
411 let b = state.pop();
412 if let Some(v) = a {
413 state.stack.push(v);
414 }
415 if let Some(v) = b {
416 state.stack.push(v);
417 }
418 }
419 "over" => {
420 if state.depth() >= 2 {
421 let second = state.stack[state.depth() - 2].clone();
422 state.stack.push(second);
423 }
424 }
425 "rot" => {
426 let c = state.pop();
427 let b = state.pop();
428 let a = state.pop();
429 if let Some(v) = b {
430 state.stack.push(v);
431 }
432 if let Some(v) = c {
433 state.stack.push(v);
434 }
435 if let Some(v) = a {
436 state.stack.push(v);
437 }
438 }
439 "tuck" => {
440 let b = state.pop();
441 let a = state.pop();
442 if let Some(v) = b.clone() {
443 state.stack.push(v);
444 }
445 if let Some(v) = a {
446 state.stack.push(v);
447 }
448 if let Some(v) = b {
449 state.stack.push(v);
450 }
451 }
452 "2dup" => {
453 if state.depth() >= 2 {
454 let a = state.stack[state.depth() - 2].clone();
455 let b = state.stack[state.depth() - 1].clone();
456 state.stack.push(a);
457 state.stack.push(b);
458 }
459 }
460 ">aux" => {
461 if let Some(v) = state.pop() {
462 state.aux.push(v);
463 }
464 }
465 "aux>" => {
466 if let Some(v) = state.aux.pop() {
467 state.stack.push(v);
468 }
469 }
470 "pick" | "roll" => {
471 state.push_other();
473 }
474
475 "dip" => {
477 state.pop(); let preserved = state.pop();
480 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
483 if let Some(v) = preserved {
484 state.stack.push(v);
485 }
486 }
487 "keep" => {
488 state.pop(); let preserved = state.pop();
491 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
492 if let Some(v) = preserved {
493 state.stack.push(v);
494 }
495 }
496 "bi" => {
497 state.pop(); state.pop(); state.pop(); state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
503 }
504
505 "call" => {
507 state.pop(); state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
510 }
511
512 "int->string" | "int->float" | "float->int" | "float->string" | "char->string"
514 | "symbol->string" | "string->symbol" => {
515 state.pop();
519 state.push_other();
520 }
521
522 "and" | "or" | "not" => {
524 state.pop();
528 if name != "not" {
529 state.pop();
530 }
531 state.push_other();
532 }
533
534 "test.assert" | "test.assert-not" => {
536 state.pop(); }
538
539 _ => {
543 }
548 }
549 }
550
551 fn emit_warning(&mut self, flag: &ErrorFlag, drop_line: usize, word: &WordDef) {
552 if drop_line <= flag.created_line + 2 {
559 return;
560 }
561
562 self.diagnostics.push(LintDiagnostic {
563 id: "unchecked-error-flag".to_string(),
564 message: format!(
565 "`{}` returns a Bool error flag (indicates {}) — dropped without checking",
566 flag.operation, flag.description,
567 ),
568 severity: Severity::Warning,
569 replacement: String::new(),
570 file: self.file.clone(),
571 line: flag.created_line,
572 end_line: Some(drop_line),
573 start_column: None,
574 end_column: None,
575 word_name: word.name.clone(),
576 start_index: 0,
577 end_index: 0,
578 });
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::ast::{Statement, WordDef};
586 use crate::types::{Effect, StackType};
587
588 fn make_word(name: &str, body: Vec<Statement>) -> WordDef {
589 WordDef {
590 name: name.to_string(),
591 effect: Some(Effect::new(StackType::Empty, StackType::Empty)),
592 body,
593 source: None,
594 allowed_lints: vec![],
595 }
596 }
597
598 fn word_call(name: &str, line: usize) -> Statement {
599 Statement::WordCall {
600 name: name.to_string(),
601 span: Some(Span {
602 line,
603 column: 0,
604 length: 1,
605 }),
606 }
607 }
608
609 #[test]
610 fn test_adjacent_drop_not_flagged() {
611 let word = make_word(
613 "test",
614 vec![
615 Statement::StringLiteral("foo".to_string()),
616 word_call("file.slurp", 1),
617 word_call("drop", 1),
618 ],
619 );
620 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
621 let diags = analyzer.analyze_word(&word);
622 assert!(
623 diags.is_empty(),
624 "Adjacent drop should be left to pattern linter"
625 );
626 }
627
628 #[test]
629 fn test_non_adjacent_drop_flagged() {
630 let word = make_word(
634 "test",
635 vec![
636 Statement::StringLiteral("foo".to_string()),
637 word_call("file.slurp", 1),
638 word_call("swap", 5),
639 word_call("nip", 10),
640 ],
641 );
642 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
643 let diags = analyzer.analyze_word(&word);
644 assert_eq!(diags.len(), 1);
645 assert_eq!(diags[0].id, "unchecked-error-flag");
646 assert!(diags[0].message.contains("file.slurp"));
647 }
648
649 #[test]
650 fn test_checked_by_if() {
651 let word = make_word(
653 "test",
654 vec![
655 Statement::StringLiteral("foo".to_string()),
656 word_call("file.slurp", 1),
657 Statement::If {
658 then_branch: vec![word_call("io.write-line", 3)],
659 else_branch: Some(vec![word_call("drop", 5)]),
660 span: Some(Span {
661 line: 2,
662 column: 0,
663 length: 2,
664 }),
665 },
666 ],
667 );
668 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
669 let diags = analyzer.analyze_word(&word);
670 assert!(diags.is_empty(), "Bool checked by if should not warn");
671 }
672
673 #[test]
674 fn test_aux_round_trip_drop() {
675 let word = make_word(
677 "test",
678 vec![
679 Statement::StringLiteral("foo".to_string()),
680 word_call("file.slurp", 1),
681 word_call(">aux", 5),
682 Statement::StringLiteral("other work".to_string()),
683 word_call("drop", 8),
684 word_call("aux>", 12),
685 word_call("drop", 15),
686 ],
687 );
688 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
689 let diags = analyzer.analyze_word(&word);
690 assert_eq!(diags.len(), 1);
691 assert!(diags[0].message.contains("file.slurp"));
692 }
693
694 #[test]
695 fn test_division_checked() {
696 let word = make_word(
698 "test",
699 vec![
700 Statement::IntLiteral(10),
701 Statement::IntLiteral(0),
702 word_call("i./", 1),
703 Statement::If {
704 then_branch: vec![],
705 else_branch: Some(vec![word_call("drop", 3)]),
706 span: Some(Span {
707 line: 2,
708 column: 0,
709 length: 2,
710 }),
711 },
712 ],
713 );
714 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
715 let diags = analyzer.analyze_word(&word);
716 assert!(diags.is_empty());
717 }
718
719 #[test]
720 fn test_nip_preserves_flag_on_top() {
721 let word = make_word(
724 "test",
725 vec![
726 Statement::StringLiteral("42".to_string()),
727 word_call("string->int", 1),
728 word_call("nip", 2),
729 ],
730 );
731 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
732 let diags = analyzer.analyze_word(&word);
733 assert!(diags.is_empty(), "nip keeps Bool on top — no warning");
734 }
735
736 #[test]
737 fn test_swap_nip_drops_flag() {
738 let word = make_word(
740 "test",
741 vec![
742 Statement::StringLiteral("42".to_string()),
743 word_call("string->int", 1),
744 word_call("swap", 5),
745 word_call("nip", 10),
746 ],
747 );
748 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
749 let diags = analyzer.analyze_word(&word);
750 assert_eq!(diags.len(), 1);
751 assert!(diags[0].message.contains("string->int"));
752 }
753
754 #[test]
755 fn test_allow_suppresses_warning() {
756 let word = WordDef {
758 name: "test".to_string(),
759 effect: Some(Effect::new(StackType::Empty, StackType::Empty)),
760 body: vec![
761 Statement::StringLiteral("foo".to_string()),
762 word_call("file.slurp", 1),
763 word_call("swap", 5),
764 word_call("nip", 10),
765 ],
766 source: None,
767 allowed_lints: vec!["unchecked-error-flag".to_string()],
768 };
769 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
770 let program = crate::ast::Program {
771 includes: vec![],
772 unions: vec![],
773 words: vec![word],
774 };
775 let diags = analyzer.analyze_program(&program);
776 assert!(diags.is_empty(), "seq:allow should suppress warning");
777 }
778
779 #[test]
780 fn test_multiple_flags_both_dropped() {
781 let word = make_word(
783 "test",
784 vec![
785 Statement::StringLiteral("foo".to_string()),
786 word_call("file.slurp", 1), word_call("swap", 5), word_call("nip", 10), word_call("string->int", 15), word_call("swap", 20),
791 word_call("nip", 25), ],
793 );
794 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
795 let diags = analyzer.analyze_word(&word);
796 assert_eq!(diags.len(), 2, "Both flags should produce warnings");
797 }
798
799 #[test]
800 fn test_dip_clears_flags_no_false_positive() {
801 let word = make_word(
804 "test",
805 vec![
806 Statement::StringLiteral("foo".to_string()),
807 word_call("file.slurp", 1), Statement::Quotation {
809 id: 0,
810 body: vec![word_call("drop", 5)],
811 span: None,
812 },
813 word_call("dip", 10),
814 ],
815 );
816 let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
817 let diags = analyzer.analyze_word(&word);
818 assert!(
819 diags.is_empty(),
820 "dip conservatively clears flags — no false positive"
821 );
822 }
823}