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_or(0, |s| s.line);
131
132        if let Some(info) = fallible_op_info(name) {
133            // Pop inputs consumed by the operation
134            for _ in 0..info.inputs {
135                state.pop();
136            }
137            // Push output values, then the error flag Bool
138            for _ in 0..info.values_before_bool {
139                state.push_other();
140            }
141            state.push_flag(line, name, info.description);
142            return;
143        }
144
145        if is_checking_consumer(name) {
146            // `cond` is a multi-way conditional that consumes quotation pairs
147            // + a count from the stack. Its variable arity means we can't
148            // precisely model what it consumes. Conservative: assume it
149            // checks any flags it touches (no warning), but don't clear
150            // the entire stack — flags below the cond args may still need checking.
151            state.pop(); // at minimum, the count argument
152            return;
153        }
154
155        self.simulate_stack_op(name, line, state, word);
156    }
157
158    /// Simulate the abstract-stack effect of a word that is neither a known
159    /// fallible operation nor a checking consumer.
160    fn simulate_stack_op(
161        &mut self,
162        name: &str,
163        line: usize,
164        state: &mut FlagStack,
165        word: &WordDef,
166    ) {
167        match name {
168            "drop" => self.pop_and_warn(state, line, word),
169            "nip" => {
170                // ( a b -- b ) — drops a (second from top)
171                let top = state.pop();
172                self.pop_and_warn(state, line, word);
173                if let Some(v) = top {
174                    state.stack.push(v);
175                }
176            }
177            "3drop" => {
178                for _ in 0..3 {
179                    self.pop_and_warn(state, line, word);
180                }
181            }
182            "2drop" => {
183                for _ in 0..2 {
184                    self.pop_and_warn(state, line, word);
185                }
186            }
187            "dup" => {
188                if let Some(top) = state.stack.last().cloned() {
189                    state.stack.push(top);
190                }
191            }
192            "swap" => {
193                let a = state.pop();
194                let b = state.pop();
195                if let Some(v) = a {
196                    state.stack.push(v);
197                }
198                if let Some(v) = b {
199                    state.stack.push(v);
200                }
201            }
202            // Guard arms ("over"/"2dup" with depth check) intentionally
203            // fall through to the catch-all `_ => { /* no-op */ }` when
204            // the guard fails. Adding a new earlier arm whose pattern
205            // could match these names (e.g. another `_ if ... =>`) would
206            // silently change behavior — keep guard-bearing arms close
207            // to the catch-all or convert back to inner-`if` form.
208            "over" if state.depth() >= 2 => {
209                let second = state.stack[state.depth() - 2].clone();
210                state.stack.push(second);
211            }
212            "rot" => {
213                let c = state.pop();
214                let b = state.pop();
215                let a = state.pop();
216                if let Some(v) = b {
217                    state.stack.push(v);
218                }
219                if let Some(v) = c {
220                    state.stack.push(v);
221                }
222                if let Some(v) = a {
223                    state.stack.push(v);
224                }
225            }
226            "tuck" => {
227                let b = state.pop();
228                let a = state.pop();
229                if let Some(v) = b.clone() {
230                    state.stack.push(v);
231                }
232                if let Some(v) = a {
233                    state.stack.push(v);
234                }
235                if let Some(v) = b {
236                    state.stack.push(v);
237                }
238            }
239            "2dup" if state.depth() >= 2 => {
240                let a = state.stack[state.depth() - 2].clone();
241                let b = state.stack[state.depth() - 1].clone();
242                state.stack.push(a);
243                state.stack.push(b);
244            }
245            ">aux" => {
246                if let Some(v) = state.pop() {
247                    state.aux.push(v);
248                }
249            }
250            "aux>" => {
251                if let Some(v) = state.aux.pop() {
252                    state.stack.push(v);
253                }
254            }
255            "pick" | "roll" => {
256                // Conservative: push unknown (can't statically know depth)
257                state.push_other();
258            }
259
260            // Combinators — dip hides top, runs quotation, restores
261            "dip" => {
262                // ( x quot -- ? x ) — pop quot, pop x, run quot (unknown effect), push x
263                state.pop(); // quotation
264                let preserved = state.pop();
265                // Quotation effect unknown — conservatively clear flags from stack
266                // (quotation might check them, might not)
267                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
268                if let Some(v) = preserved {
269                    state.stack.push(v);
270                }
271            }
272            "keep" => {
273                // ( x quot -- ? x ) — similar to dip but quotation gets x
274                state.pop(); // quotation
275                let preserved = state.pop();
276                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
277                if let Some(v) = preserved {
278                    state.stack.push(v);
279                }
280            }
281            "bi" => {
282                // ( x q1 q2 -- ? ) — two quotations consume x
283                state.pop(); // q2
284                state.pop(); // q1
285                state.pop(); // x
286                // Both quotations have unknown effects
287                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
288            }
289
290            // call — quotation effect unknown, conservatively assume it checks
291            "call" => {
292                state.pop(); // quotation
293                // Conservative: clear tracked flags (quotation might do anything)
294                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
295            }
296
297            // Known type-conversion words that consume one value and push one
298            "int->string" | "int->float" | "float->int" | "float->string" | "char->string"
299            | "symbol->string" | "string->symbol" => {
300                // These consume the top value. If it's a flag, that's suspicious
301                // but not necessarily wrong (e.g., converting a Bool to string for display).
302                // Conservative: don't warn, just remove tracking.
303                state.pop();
304                state.push_other();
305            }
306
307            // Boolean operations that legitimately consume Bools
308            "and" | "or" | "not" => {
309                // These consume Bool(s) and produce Bool — not a check per se,
310                // but the user is clearly working with the Bool value.
311                // Conservative: mark as consumed (no warning).
312                state.pop();
313                if name != "not" {
314                    state.pop();
315                }
316                state.push_other();
317            }
318
319            // Test assertions that check Bools
320            "test.assert" | "test.assert-not" => {
321                state.pop(); // Bool consumed by assertion = checked
322            }
323
324            // All other words: conservative — assume they consume/produce
325            // unknown values. Pop any flags without warning (might be checked
326            // inside the word).
327            _ => {
328                // For unknown words, we don't know the stack effect.
329                // Conservative: leave the stack as-is (don't warn, don't clear).
330                // This avoids false positives from user-defined words that
331                // properly handle the Bool internally.
332            }
333        }
334    }
335
336    /// Pop one value; if it carries a tracked error flag, warn that the flag
337    /// was dropped without being checked.
338    fn pop_and_warn(&mut self, state: &mut FlagStack, line: usize, word: &WordDef) {
339        if let Some(StackVal::Flag(flag)) = state.pop() {
340            self.emit_warning(&flag, line, word);
341        }
342    }
343
344    fn emit_warning(&mut self, flag: &ErrorFlag, drop_line: usize, word: &WordDef) {
345        // Don't warn if the drop is adjacent to the operation (within 2 lines).
346        // Adjacent drops like `net.tcp.write drop` are covered by the pattern-based
347        // linter with better precision (exact column info, replacement suggestions).
348        // We only add value for non-adjacent drops (e.g., swap nip, aux round-trips).
349        // Note: if spans are missing, both lines default to 0 and this suppresses
350        // the warning — acceptable since span-less nodes are rare (synthetic AST only).
351        if drop_line <= flag.created_line + 2 {
352            return;
353        }
354
355        self.diagnostics.push(LintDiagnostic {
356            id: "unchecked-error-flag".to_string(),
357            message: format!(
358                "`{}` returns a Bool error flag (indicates {}) — dropped without checking",
359                flag.operation, flag.description,
360            ),
361            severity: Severity::Warning,
362            replacement: String::new(),
363            file: self.file.clone(),
364            line: flag.created_line,
365            end_line: Some(drop_line),
366            start_column: None,
367            end_column: None,
368            word_name: word.name.clone(),
369            start_index: 0,
370            end_index: 0,
371        });
372    }
373}