Skip to main content

seqc/error_flag_lint/
analyzer.rs

1//! The `ErrorFlagAnalyzer` walks the AST, drives the abstract flag-stack
2//! simulation, and emits lint diagnostics when tagged Bools are dropped
3//! without being checked.
4
5use std::path::{Path, PathBuf};
6
7use crate::ast::{Program, Span, Statement, WordDef};
8use crate::lint::{LintDiagnostic, Severity};
9
10use super::state::{ErrorFlag, FlagStack, StackVal, fallible_op_info, is_checking_consumer};
11
12pub struct ErrorFlagAnalyzer {
13    file: PathBuf,
14    diagnostics: Vec<LintDiagnostic>,
15}
16
17impl ErrorFlagAnalyzer {
18    pub fn new(file: &Path) -> Self {
19        ErrorFlagAnalyzer {
20            file: file.to_path_buf(),
21            diagnostics: Vec::new(),
22        }
23    }
24
25    pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
26        let mut all_diagnostics = Vec::new();
27        for word in &program.words {
28            // Skip words with seq:allow(unchecked-error-flag)
29            if word
30                .allowed_lints
31                .iter()
32                .any(|l| l == "unchecked-error-flag")
33            {
34                continue;
35            }
36            let diags = self.analyze_word(word);
37            all_diagnostics.extend(diags);
38        }
39        all_diagnostics
40    }
41
42    pub(super) fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
43        self.diagnostics.clear();
44        let mut state = FlagStack::new();
45        self.analyze_statements(&word.body, &mut state, word);
46        // Flags remaining on stack at word end = returned to caller (escape)
47        std::mem::take(&mut self.diagnostics)
48    }
49
50    fn analyze_statements(
51        &mut self,
52        statements: &[Statement],
53        state: &mut FlagStack,
54        word: &WordDef,
55    ) {
56        for stmt in statements {
57            self.analyze_statement(stmt, state, word);
58        }
59    }
60
61    fn analyze_statement(&mut self, stmt: &Statement, state: &mut FlagStack, word: &WordDef) {
62        match stmt {
63            Statement::IntLiteral(_)
64            | Statement::FloatLiteral(_)
65            | Statement::BoolLiteral(_)
66            | Statement::StringLiteral(_)
67            | Statement::Symbol(_) => {
68                state.push_other();
69            }
70
71            Statement::Quotation { .. } => {
72                state.push_other();
73            }
74
75            Statement::WordCall { name, span } => {
76                self.analyze_word_call(name, span.as_ref(), state, word);
77            }
78
79            Statement::If {
80                then_branch,
81                else_branch,
82                span: _,
83            } => {
84                // `if` consumes the Bool on top — this IS a check
85                state.pop();
86
87                let mut then_state = state.clone();
88                let mut else_state = state.clone();
89                self.analyze_statements(then_branch, &mut then_state, word);
90                if let Some(else_stmts) = else_branch {
91                    self.analyze_statements(else_stmts, &mut else_state, word);
92                }
93                *state = then_state.join(&else_state);
94            }
95
96            Statement::Match { arms, span: _ } => {
97                state.pop(); // match value consumed
98                let mut arm_states: Vec<FlagStack> = Vec::new();
99                for arm in arms {
100                    let mut arm_state = state.clone();
101                    // Match arm bindings push values onto stack
102                    match &arm.pattern {
103                        crate::ast::Pattern::Variant(_) => {
104                            // Variant without named bindings — field count unknown
105                            // statically. Same limitation as resource_lint.
106                        }
107                        crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
108                            for _binding in bindings {
109                                arm_state.push_other();
110                            }
111                        }
112                    }
113                    self.analyze_statements(&arm.body, &mut arm_state, word);
114                    arm_states.push(arm_state);
115                }
116                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
117                    *state = joined;
118                }
119            }
120        }
121    }
122
123    pub(super) fn analyze_word_call(
124        &mut self,
125        name: &str,
126        span: Option<&Span>,
127        state: &mut FlagStack,
128        word: &WordDef,
129    ) {
130        let line = span.map(|s| s.line).unwrap_or(0);
131
132        // Check if this is a fallible operation
133        if let Some(info) = fallible_op_info(name) {
134            // Pop inputs consumed by the operation
135            for _ in 0..info.inputs {
136                state.pop();
137            }
138            // Push output values, then the error flag Bool
139            for _ in 0..info.values_before_bool {
140                state.push_other();
141            }
142            state.push_flag(line, name, info.description);
143            return;
144        }
145
146        // Check if this is a checking consumer
147        if is_checking_consumer(name) {
148            // `cond` is a multi-way conditional that consumes quotation pairs
149            // + a count from the stack. Its variable arity means we can't
150            // precisely model what it consumes. Conservative: assume it
151            // checks any flags it touches (no warning), but don't clear
152            // the entire stack — flags below the cond args may still need checking.
153            state.pop(); // at minimum, the count argument
154            return;
155        }
156
157        // Stack operations — simulate movement
158        match name {
159            "drop" => {
160                if let Some(StackVal::Flag(flag)) = state.pop() {
161                    self.emit_warning(&flag, line, word);
162                }
163            }
164            "nip" => {
165                // ( a b -- b ) — drops a (second from top)
166                let top = state.pop();
167                if let Some(StackVal::Flag(flag)) = state.pop() {
168                    self.emit_warning(&flag, line, word);
169                }
170                if let Some(v) = top {
171                    state.stack.push(v);
172                }
173            }
174            "3drop" => {
175                for _ in 0..3 {
176                    if let Some(StackVal::Flag(flag)) = state.pop() {
177                        self.emit_warning(&flag, line, word);
178                    }
179                }
180            }
181            "2drop" => {
182                for _ in 0..2 {
183                    if let Some(StackVal::Flag(flag)) = state.pop() {
184                        self.emit_warning(&flag, line, word);
185                    }
186                }
187            }
188            "dup" => {
189                if let Some(top) = state.stack.last().cloned() {
190                    state.stack.push(top);
191                }
192            }
193            "swap" => {
194                let a = state.pop();
195                let b = state.pop();
196                if let Some(v) = a {
197                    state.stack.push(v);
198                }
199                if let Some(v) = b {
200                    state.stack.push(v);
201                }
202            }
203            "over" => {
204                if state.depth() >= 2 {
205                    let second = state.stack[state.depth() - 2].clone();
206                    state.stack.push(second);
207                }
208            }
209            "rot" => {
210                let c = state.pop();
211                let b = state.pop();
212                let a = state.pop();
213                if let Some(v) = b {
214                    state.stack.push(v);
215                }
216                if let Some(v) = c {
217                    state.stack.push(v);
218                }
219                if let Some(v) = a {
220                    state.stack.push(v);
221                }
222            }
223            "tuck" => {
224                let b = state.pop();
225                let a = state.pop();
226                if let Some(v) = b.clone() {
227                    state.stack.push(v);
228                }
229                if let Some(v) = a {
230                    state.stack.push(v);
231                }
232                if let Some(v) = b {
233                    state.stack.push(v);
234                }
235            }
236            "2dup" => {
237                if state.depth() >= 2 {
238                    let a = state.stack[state.depth() - 2].clone();
239                    let b = state.stack[state.depth() - 1].clone();
240                    state.stack.push(a);
241                    state.stack.push(b);
242                }
243            }
244            ">aux" => {
245                if let Some(v) = state.pop() {
246                    state.aux.push(v);
247                }
248            }
249            "aux>" => {
250                if let Some(v) = state.aux.pop() {
251                    state.stack.push(v);
252                }
253            }
254            "pick" | "roll" => {
255                // Conservative: push unknown (can't statically know depth)
256                state.push_other();
257            }
258
259            // Combinators — dip hides top, runs quotation, restores
260            "dip" => {
261                // ( x quot -- ? x ) — pop quot, pop x, run quot (unknown effect), push x
262                state.pop(); // quotation
263                let preserved = state.pop();
264                // Quotation effect unknown — conservatively clear flags from stack
265                // (quotation might check them, might not)
266                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
267                if let Some(v) = preserved {
268                    state.stack.push(v);
269                }
270            }
271            "keep" => {
272                // ( x quot -- ? x ) — similar to dip but quotation gets x
273                state.pop(); // quotation
274                let preserved = state.pop();
275                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
276                if let Some(v) = preserved {
277                    state.stack.push(v);
278                }
279            }
280            "bi" => {
281                // ( x q1 q2 -- ? ) — two quotations consume x
282                state.pop(); // q2
283                state.pop(); // q1
284                state.pop(); // x
285                // Both quotations have unknown effects
286                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
287            }
288
289            // call — quotation effect unknown, conservatively assume it checks
290            "call" => {
291                state.pop(); // quotation
292                // Conservative: clear tracked flags (quotation might do anything)
293                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
294            }
295
296            // Known type-conversion words that consume one value and push one
297            "int->string" | "int->float" | "float->int" | "float->string" | "char->string"
298            | "symbol->string" | "string->symbol" => {
299                // These consume the top value. If it's a flag, that's suspicious
300                // but not necessarily wrong (e.g., converting a Bool to string for display).
301                // Conservative: don't warn, just remove tracking.
302                state.pop();
303                state.push_other();
304            }
305
306            // Boolean operations that legitimately consume Bools
307            "and" | "or" | "not" => {
308                // These consume Bool(s) and produce Bool — not a check per se,
309                // but the user is clearly working with the Bool value.
310                // Conservative: mark as consumed (no warning).
311                state.pop();
312                if name != "not" {
313                    state.pop();
314                }
315                state.push_other();
316            }
317
318            // Test assertions that check Bools
319            "test.assert" | "test.assert-not" => {
320                state.pop(); // Bool consumed by assertion = checked
321            }
322
323            // All other words: conservative — assume they consume/produce
324            // unknown values. Pop any flags without warning (might be checked
325            // inside the word).
326            _ => {
327                // For unknown words, we don't know the stack effect.
328                // Conservative: leave the stack as-is (don't warn, don't clear).
329                // This avoids false positives from user-defined words that
330                // properly handle the Bool internally.
331            }
332        }
333    }
334
335    fn emit_warning(&mut self, flag: &ErrorFlag, drop_line: usize, word: &WordDef) {
336        // Don't warn if the drop is adjacent to the operation (within 2 lines).
337        // Adjacent drops like `tcp.write drop` are covered by the pattern-based
338        // linter with better precision (exact column info, replacement suggestions).
339        // We only add value for non-adjacent drops (e.g., swap nip, aux round-trips).
340        // Note: if spans are missing, both lines default to 0 and this suppresses
341        // the warning — acceptable since span-less nodes are rare (synthetic AST only).
342        if drop_line <= flag.created_line + 2 {
343            return;
344        }
345
346        self.diagnostics.push(LintDiagnostic {
347            id: "unchecked-error-flag".to_string(),
348            message: format!(
349                "`{}` returns a Bool error flag (indicates {}) — dropped without checking",
350                flag.operation, flag.description,
351            ),
352            severity: Severity::Warning,
353            replacement: String::new(),
354            file: self.file.clone(),
355            line: flag.created_line,
356            end_line: Some(drop_line),
357            start_column: None,
358            end_column: None,
359            word_name: word.name.clone(),
360            start_index: 0,
361            end_index: 0,
362        });
363    }
364}