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 // Guard arms ("over"/"2dup" with depth check) intentionally
204 // fall through to the catch-all `_ => { /* no-op */ }` when
205 // the guard fails. Adding a new earlier arm whose pattern
206 // could match these names (e.g. another `_ if ... =>`) would
207 // silently change behavior — keep guard-bearing arms close
208 // to the catch-all or convert back to inner-`if` form.
209 "over" if state.depth() >= 2 => {
210 let second = state.stack[state.depth() - 2].clone();
211 state.stack.push(second);
212 }
213 "rot" => {
214 let c = state.pop();
215 let b = state.pop();
216 let a = state.pop();
217 if let Some(v) = b {
218 state.stack.push(v);
219 }
220 if let Some(v) = c {
221 state.stack.push(v);
222 }
223 if let Some(v) = a {
224 state.stack.push(v);
225 }
226 }
227 "tuck" => {
228 let b = state.pop();
229 let a = state.pop();
230 if let Some(v) = b.clone() {
231 state.stack.push(v);
232 }
233 if let Some(v) = a {
234 state.stack.push(v);
235 }
236 if let Some(v) = b {
237 state.stack.push(v);
238 }
239 }
240 "2dup" if state.depth() >= 2 => {
241 let a = state.stack[state.depth() - 2].clone();
242 let b = state.stack[state.depth() - 1].clone();
243 state.stack.push(a);
244 state.stack.push(b);
245 }
246 ">aux" => {
247 if let Some(v) = state.pop() {
248 state.aux.push(v);
249 }
250 }
251 "aux>" => {
252 if let Some(v) = state.aux.pop() {
253 state.stack.push(v);
254 }
255 }
256 "pick" | "roll" => {
257 // Conservative: push unknown (can't statically know depth)
258 state.push_other();
259 }
260
261 // Combinators — dip hides top, runs quotation, restores
262 "dip" => {
263 // ( x quot -- ? x ) — pop quot, pop x, run quot (unknown effect), push x
264 state.pop(); // quotation
265 let preserved = state.pop();
266 // Quotation effect unknown — conservatively clear flags from stack
267 // (quotation might check them, might not)
268 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
269 if let Some(v) = preserved {
270 state.stack.push(v);
271 }
272 }
273 "keep" => {
274 // ( x quot -- ? x ) — similar to dip but quotation gets x
275 state.pop(); // quotation
276 let preserved = state.pop();
277 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
278 if let Some(v) = preserved {
279 state.stack.push(v);
280 }
281 }
282 "bi" => {
283 // ( x q1 q2 -- ? ) — two quotations consume x
284 state.pop(); // q2
285 state.pop(); // q1
286 state.pop(); // x
287 // Both quotations have unknown effects
288 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
289 }
290
291 // call — quotation effect unknown, conservatively assume it checks
292 "call" => {
293 state.pop(); // quotation
294 // Conservative: clear tracked flags (quotation might do anything)
295 state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
296 }
297
298 // Known type-conversion words that consume one value and push one
299 "int->string" | "int->float" | "float->int" | "float->string" | "char->string"
300 | "symbol->string" | "string->symbol" => {
301 // These consume the top value. If it's a flag, that's suspicious
302 // but not necessarily wrong (e.g., converting a Bool to string for display).
303 // Conservative: don't warn, just remove tracking.
304 state.pop();
305 state.push_other();
306 }
307
308 // Boolean operations that legitimately consume Bools
309 "and" | "or" | "not" => {
310 // These consume Bool(s) and produce Bool — not a check per se,
311 // but the user is clearly working with the Bool value.
312 // Conservative: mark as consumed (no warning).
313 state.pop();
314 if name != "not" {
315 state.pop();
316 }
317 state.push_other();
318 }
319
320 // Test assertions that check Bools
321 "test.assert" | "test.assert-not" => {
322 state.pop(); // Bool consumed by assertion = checked
323 }
324
325 // All other words: conservative — assume they consume/produce
326 // unknown values. Pop any flags without warning (might be checked
327 // inside the word).
328 _ => {
329 // For unknown words, we don't know the stack effect.
330 // Conservative: leave the stack as-is (don't warn, don't clear).
331 // This avoids false positives from user-defined words that
332 // properly handle the Bool internally.
333 }
334 }
335 }
336
337 fn emit_warning(&mut self, flag: &ErrorFlag, drop_line: usize, word: &WordDef) {
338 // Don't warn if the drop is adjacent to the operation (within 2 lines).
339 // Adjacent drops like `tcp.write drop` are covered by the pattern-based
340 // linter with better precision (exact column info, replacement suggestions).
341 // We only add value for non-adjacent drops (e.g., swap nip, aux round-trips).
342 // Note: if spans are missing, both lines default to 0 and this suppresses
343 // the warning — acceptable since span-less nodes are rare (synthetic AST only).
344 if drop_line <= flag.created_line + 2 {
345 return;
346 }
347
348 self.diagnostics.push(LintDiagnostic {
349 id: "unchecked-error-flag".to_string(),
350 message: format!(
351 "`{}` returns a Bool error flag (indicates {}) — dropped without checking",
352 flag.operation, flag.description,
353 ),
354 severity: Severity::Warning,
355 replacement: String::new(),
356 file: self.file.clone(),
357 line: flag.created_line,
358 end_line: Some(drop_line),
359 start_column: None,
360 end_column: None,
361 word_name: word.name.clone(),
362 start_index: 0,
363 end_index: 0,
364 });
365 }
366}