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}