Skip to main content

safe_migrate/engine/
engine.rs

1use crate::analysis::mutations::Mutation;
2use crate::analysis::resolver::Resolver;
3use crate::analysis::state::AnalysisState;
4use crate::ast::visitor::AstVisitor;
5use crate::engine::config::Config;
6use crate::report::violations::{ReportFinding, SourceLocation, Violation};
7use crate::rules::Rule;
8use crate::rules::registry;
9use squawk_syntax::{
10    SyntaxKind,
11    ast::{AstNode, SourceFile},
12};
13use std::collections::HashSet;
14
15pub struct SafeMigrateEngine {
16    config: Config,
17    rules: Vec<Box<dyn Rule>>,
18}
19
20impl SafeMigrateEngine {
21    pub fn new(config: Config) -> Self {
22        Self {
23            config,
24            rules: registry::build_primary_rules(),
25        }
26    }
27
28    /// Returns primary rule IDs in evaluation order.
29    pub fn primary_rule_ids(&self) -> Vec<&'static str> {
30        registry::primary_rule_ids().collect()
31    }
32
33    pub fn analyze_chain(
34        &self,
35        files: &[(String, String)],
36        state: &mut AnalysisState,
37    ) -> Result<Vec<Violation>, Vec<String>> {
38        let mut all_violations = Vec::new();
39        for (filename, sql) in files {
40            let violations = self.analyze_single_file(filename, sql, state)?;
41            all_violations.extend(violations);
42        }
43        // Stable ordering keeps reports reproducible across files.
44        all_violations.sort_by(|a, b| {
45            a.tier
46                .cmp(&b.tier)
47                .then_with(|| match (&a.source_range, &b.source_range) {
48                    (Some(ar), Some(br)) => ar
49                        .start()
50                        .cmp(&br.start())
51                        .then_with(|| ar.end().cmp(&br.end())),
52                    (Some(_), None) => std::cmp::Ordering::Less,
53                    (None, Some(_)) => std::cmp::Ordering::Greater,
54                    (None, None) => std::cmp::Ordering::Equal,
55                })
56                .then_with(|| a.object_name.cmp(&b.object_name))
57                .then_with(|| a.rule_id.cmp(b.rule_id))
58        });
59        Ok(all_violations)
60    }
61
62    pub fn analyze(
63        &self,
64        sql: &str,
65        state: &mut AnalysisState,
66    ) -> Result<Vec<Violation>, Vec<String>> {
67        self.analyze_chain(&[("<inline>".to_string(), sql.to_string())], state)
68    }
69
70    /// Analyze ordered files and retain reportable source locations for every
71    /// finding. The original `analyze_chain` API remains available to callers
72    /// that only need violations.
73    pub fn analyze_chain_with_locations(
74        &self,
75        files: &[(String, String)],
76        state: &mut AnalysisState,
77    ) -> Result<Vec<ReportFinding>, Vec<String>> {
78        let mut findings = Vec::new();
79
80        for (file_index, (filename, sql)) in files.iter().enumerate() {
81            let normalized_sql = Self::normalize_execute(sql);
82            let parsed = SourceFile::parse(&normalized_sql);
83            let statement_ranges: Vec<_> = parsed
84                .tree()
85                .stmts()
86                .map(|statement| statement.syntax().text_range())
87                .collect();
88            let violations = self.analyze_normalized_file(filename, &normalized_sql, state)?;
89            findings.extend(
90                violations
91                    .into_iter()
92                    .map(|violation| ReportFinding {
93                        location: Self::source_location(
94                            filename,
95                            &normalized_sql,
96                            violation.source_range,
97                        ),
98                        statement_index: violation.source_range.and_then(|range| {
99                            statement_ranges
100                                .iter()
101                                .position(|statement| statement.contains_range(range))
102                                .map(|index| index + 1)
103                        }),
104                        violation,
105                    })
106                    .map(|finding| (file_index, finding)),
107            );
108        }
109
110        findings.sort_by(|(a_index, a), (b_index, b)| {
111            a.violation
112                .tier
113                .cmp(&b.violation.tier)
114                .then_with(|| a_index.cmp(b_index))
115                .then_with(|| match (&a.location, &b.location) {
116                    (Some(a_location), Some(b_location)) => a_location
117                        .line
118                        .cmp(&b_location.line)
119                        .then_with(|| a_location.column.cmp(&b_location.column)),
120                    (Some(_), None) => std::cmp::Ordering::Less,
121                    (None, Some(_)) => std::cmp::Ordering::Greater,
122                    (None, None) => std::cmp::Ordering::Equal,
123                })
124                .then_with(|| a.violation.object_name.cmp(&b.violation.object_name))
125                .then_with(|| a.violation.rule_id.cmp(b.violation.rule_id))
126        });
127
128        Ok(findings.into_iter().map(|(_, finding)| finding).collect())
129    }
130
131    pub fn analyze_with_locations(
132        &self,
133        filename: String,
134        sql: String,
135        state: &mut AnalysisState,
136    ) -> Result<Vec<ReportFinding>, Vec<String>> {
137        self.analyze_chain_with_locations(&[(filename, sql)], state)
138    }
139
140    fn analyze_single_file(
141        &self,
142        filename: &str,
143        sql: &str,
144        state: &mut AnalysisState,
145    ) -> Result<Vec<Violation>, Vec<String>> {
146        let sql = Self::normalize_execute(sql);
147        self.analyze_normalized_file(filename, &sql, state)
148    }
149
150    fn analyze_normalized_file(
151        &self,
152        _filename: &str,
153        sql: &str,
154        state: &mut AnalysisState,
155    ) -> Result<Vec<Violation>, Vec<String>> {
156        let parsed = SourceFile::parse(sql);
157        let errors: Vec<String> = parsed.errors().iter().map(|e| e.to_string()).collect();
158        if !errors.is_empty() {
159            return Err(errors);
160        }
161
162        let mut all_violations = Vec::new();
163        let mut warned_keys = HashSet::new();
164
165        let mut file_ignores = HashSet::new();
166        for token in parsed
167            .tree()
168            .syntax()
169            .descendants_with_tokens()
170            .filter_map(|it| it.into_token())
171            .filter(|token| token.kind() == SyntaxKind::COMMENT)
172        {
173            let mut dummy = HashSet::new();
174            Self::parse_directives(token.text(), &mut file_ignores, &mut dummy);
175        }
176
177        for stmt in parsed.tree().stmts() {
178            let mut stmt_ignores = HashSet::new();
179
180            let mut prev = stmt.syntax().prev_sibling_or_token();
181            while let Some(element) = prev {
182                if element.as_node().is_some() {
183                    break;
184                }
185                if let Some(token) = element.as_token()
186                    && token.kind() == SyntaxKind::COMMENT
187                {
188                    let mut dummy = HashSet::new();
189                    Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
190                }
191                prev = element.prev_sibling_or_token();
192            }
193
194            for token in stmt
195                .syntax()
196                .descendants_with_tokens()
197                .filter_map(|it| it.into_token())
198                .filter(|token| token.kind() == SyntaxKind::COMMENT)
199            {
200                let mut dummy = HashSet::new();
201                Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
202            }
203
204            // Capture raw statement text for sql field on violations (strip leading comments)
205            let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
206
207            // PostgreSQL executes a statement atomically. Keep both state
208            // and diagnostics local until all resolved actions succeed so an
209            // earlier action in a failed compound statement cannot leak
210            // state, findings, or deduplication keys. A parsed statement
211            // without a typed extractor is explicitly opaque: silently
212            // ignoring it would claim exact confidence for later SQL.
213            let statement_checkpoint = state.clone();
214            let statement_confidence = state.local.confidence.clone();
215            let mut statement_violations = Vec::new();
216            let mut statement_warned_keys = HashSet::new();
217            let mut mutations = match AstVisitor::extract(&stmt) {
218                Some(fact) => Resolver::resolve(&fact, state),
219                None => vec![Mutation::Opaque(
220                    crate::analysis::mutations::OpaqueMutation::UnsupportedStatement,
221                )],
222            };
223            if squawk_linter::analyze::possibly_slow_stmt(&stmt) {
224                mutations.push(Mutation::CheckTimeouts);
225            }
226
227            for mutation in mutations {
228                let pre_cascade = match &mutation {
229                    Mutation::DropTable(d) if d.cascade => Some(state.get_cascade_closure(&d.id)),
230                    _ => None,
231                };
232
233                let pre_state = state.capture_pre_state();
234                let result = state.apply(&mutation, pre_cascade.as_ref());
235
236                let statement_failed = matches!(
237                    result,
238                    crate::analysis::state::MutationResult::Conflict { .. }
239                );
240                if statement_failed {
241                    let transaction_aborted = state.local.transaction_aborted;
242                    *state = statement_checkpoint.clone();
243                    if transaction_aborted && !state.local.transactions.is_empty() {
244                        state.local.transaction_aborted = true;
245                    }
246                    statement_violations.clear();
247                    statement_warned_keys.clear();
248                }
249
250                if result == crate::analysis::state::MutationResult::NotExecuted {
251                    continue;
252                }
253
254                for rule in &self.rules {
255                    if file_ignores.contains(rule.id())
256                        || stmt_ignores.contains(rule.id())
257                        || self.config.is_rule_disabled(rule.id())
258                    {
259                        continue;
260                    }
261
262                    let violations = rule.evaluate(
263                        &mutation,
264                        &result,
265                        &pre_state,
266                        state,
267                        &self.config,
268                        pre_cascade.as_ref(),
269                    );
270
271                    for v in violations {
272                        if let Some(key) = &v.dedup_key
273                            && (warned_keys.contains(key)
274                                || !statement_warned_keys.insert(key.clone()))
275                        {
276                            continue;
277                        }
278                        let mut v = v;
279                        if v.source_range.is_none() {
280                            let start = stmt
281                                .syntax()
282                                .descendants_with_tokens()
283                                .filter_map(|element| element.into_token())
284                                .find(|token| {
285                                    let text = token.text().trim();
286                                    !text.is_empty()
287                                        && !text.starts_with("--")
288                                        && !text.starts_with("/*")
289                                })
290                                .map(|token| token.text_range().start())
291                                .unwrap_or_else(|| stmt.syntax().text_range().start());
292                            let end = stmt.syntax().text_range().end();
293                            v.source_range = Some(rowan::TextRange::new(start, end));
294                        }
295                        if v.sql.is_none() {
296                            if let Some(range) = v.source_range {
297                                let start = usize::from(range.start());
298                                let end = usize::from(range.end());
299                                if start < sql.len() && end <= sql.len() {
300                                    v.sql = Some(sql[start..end].trim().to_string());
301                                } else {
302                                    v.sql = Some(stmt_text.trim().to_string());
303                                }
304                            } else {
305                                v.sql = Some(stmt_text.trim().to_string());
306                            }
307                        }
308                        // A taint produced by this statement must not
309                        // downgrade that same statement's findings.
310                        if statement_confidence == crate::analysis::state::Confidence::Tainted
311                            && v.tier == crate::report::violations::ViolationTier::Tier1
312                        {
313                            v.tier = crate::report::violations::ViolationTier::Tier2;
314                        }
315                        statement_violations.push(v);
316                    }
317                }
318
319                if statement_failed {
320                    break;
321                }
322            }
323
324            warned_keys.extend(statement_warned_keys);
325            all_violations.extend(statement_violations);
326        }
327
328        Ok(all_violations)
329    }
330
331    fn source_location(
332        filename: &str,
333        sql: &str,
334        source_range: Option<rowan::TextRange>,
335    ) -> Option<SourceLocation> {
336        let start = usize::from(source_range?.start());
337        if start > sql.len() || !sql.is_char_boundary(start) {
338            return None;
339        }
340
341        let before = &sql[..start];
342        let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1;
343        let column = before
344            .rsplit_once('\n')
345            .map_or(before, |(_, final_line)| final_line)
346            .chars()
347            .count()
348            + 1;
349        Some(SourceLocation {
350            file: filename.to_string(),
351            line,
352            column,
353        })
354    }
355
356    /// Pre-process SQL to handle EXECUTE '...' which Squawk's parser does not
357    /// recognize (top-level EXECUTE expects a prepared-statement name, not a
358    /// string literal). Rewriting to DO lets the parser produce a proper
359    /// DoBlock node. Keep the replacement byte-for-byte the same length so
360    /// source ranges still point into the original migration text.
361    fn normalize_execute(sql: &str) -> String {
362        let mut out = String::with_capacity(sql.len());
363        for line in sql.split_inclusive('\n') {
364            let trimmed = line.trim_start();
365            let bytes = trimmed.as_bytes();
366            if bytes.len() > 9 && bytes[..9].eq_ignore_ascii_case(b"EXECUTE '") {
367                let indent = &line[..line.len() - trimmed.len()];
368                out.push_str(indent);
369                out.push_str("DO      '");
370                out.push_str(&trimmed[9..]);
371            } else if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"EXECUTE $$") {
372                let indent = &line[..line.len() - trimmed.len()];
373                out.push_str(indent);
374                out.push_str("DO      $$");
375                out.push_str(&trimmed[10..]);
376            } else {
377                out.push_str(line);
378            }
379        }
380        out
381    }
382
383    fn parse_directives(
384        text: &str,
385        file_ignores: &mut HashSet<String>,
386        stmt_ignores: &mut HashSet<String>,
387    ) {
388        let marker = "safe-migrate:";
389        let mut pos = 0;
390
391        while let Some(start) = text[pos..].find(marker) {
392            let after = text[pos + start + marker.len()..].trim_start();
393
394            if let Some(rest) = after.strip_prefix("ignore-file") {
395                let rest = rest.trim_start();
396                if let Some(inner) = rest
397                    .strip_prefix('(')
398                    .and_then(|s| s.find(')').map(|e| &s[..e]))
399                {
400                    file_ignores.insert(inner.trim().to_string());
401                }
402            } else if let Some(rest) = after.strip_prefix("ignore") {
403                let rest = rest.trim_start();
404                if let Some(inner) = rest
405                    .strip_prefix('(')
406                    .and_then(|s| s.find(')').map(|e| &s[..e]))
407                {
408                    stmt_ignores.insert(inner.trim().to_string());
409                }
410            }
411
412            pos = pos + start + marker.len();
413        }
414    }
415
416    fn strip_sql_leading_comments(s: &str) -> String {
417        let mut pos = 0;
418        let bytes = s.as_bytes();
419        while pos < bytes.len() {
420            while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
421                pos += 1;
422            }
423            if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
424                while pos < bytes.len() && bytes[pos] != b'\n' {
425                    pos += 1;
426                }
427                continue;
428            }
429            if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
430                pos += 2;
431                while pos + 1 < bytes.len() && !(bytes[pos] == b'*' && bytes[pos + 1] == b'/') {
432                    pos += 1;
433                }
434                if pos + 1 < bytes.len() {
435                    pos += 2;
436                }
437                continue;
438            }
439            break;
440        }
441        s[pos..].to_string()
442    }
443}