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 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 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 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 let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
206
207 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 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 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}