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}