1use std::collections::HashSet;
53
54use sqlparser::dialect::SQLiteDialect;
55use sqlparser::keywords::Keyword;
56use sqlparser::tokenizer::{Location, Token, TokenWithSpan, Tokenizer};
57use thiserror::Error;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
67pub struct SourcePosition {
68 pub line: usize,
70 pub column: usize,
72 pub offset: usize,
74 pub length: usize,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
80pub struct Placeholder {
81 pub name: String,
84 pub position: SourcePosition,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
90pub struct TableRef {
91 pub name: String,
95 pub position: SourcePosition,
96}
97
98#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
101pub struct ScanResult {
102 pub placeholders: Vec<Placeholder>,
103 pub tables: Vec<TableRef>,
104}
105
106#[derive(Debug, Error)]
111pub enum ScanError {
112 #[error("SQL tokenizer error: {0}")]
113 Tokenize(String),
114}
115
116pub fn scan_sql(sql: &str) -> Result<ScanResult, ScanError> {
123 let dialect = SQLiteDialect {};
124 let tokens = Tokenizer::new(&dialect, sql)
125 .tokenize_with_location()
126 .map_err(|e| ScanError::Tokenize(e.to_string()))?;
127
128 let line_starts = compute_line_starts(sql);
131
132 let significant: Vec<&TokenWithSpan> = tokens
133 .iter()
134 .filter(|t| !matches!(t.token, Token::Whitespace(_)))
135 .collect();
136
137 Ok(ScanResult {
138 placeholders: scan_placeholders(&significant, &line_starts),
139 tables: scan_tables(&significant, &line_starts),
140 })
141}
142
143pub fn undeclared_tables<'a>(
147 result: &'a ScanResult,
148 declared_labels: &[String],
149) -> Vec<&'a TableRef> {
150 let declared: HashSet<String> = declared_labels.iter().map(|l| l.to_lowercase()).collect();
151 result
152 .tables
153 .iter()
154 .filter(|t| !declared.contains(&t.name.to_lowercase()))
155 .collect()
156}
157
158fn compute_line_starts(sql: &str) -> Vec<usize> {
165 let mut starts = vec![0usize];
166 for (idx, ch) in sql.chars().enumerate() {
167 if ch == '\n' {
168 starts.push(idx + 1);
169 }
170 }
171 starts
172}
173
174fn to_offset(line_starts: &[usize], loc: Location) -> usize {
178 let line_idx = (loc.line as usize).saturating_sub(1);
179 let line_start = line_starts
180 .get(line_idx)
181 .copied()
182 .unwrap_or_else(|| line_starts.last().copied().unwrap_or(0));
183 line_start + (loc.column as usize).saturating_sub(1)
184}
185
186fn source_position(line_starts: &[usize], start: Location, end: Location) -> SourcePosition {
187 let offset = to_offset(line_starts, start);
188 let end_offset = to_offset(line_starts, end);
189 SourcePosition {
190 line: start.line as usize,
191 column: start.column as usize,
192 offset,
193 length: end_offset.saturating_sub(offset),
194 }
195}
196
197fn scan_placeholders(tokens: &[&TokenWithSpan], line_starts: &[usize]) -> Vec<Placeholder> {
200 let mut seen = HashSet::new();
201 let mut out = Vec::new();
202 for pair in tokens.windows(2) {
203 let (colon, word) = (pair[0], pair[1]);
204 if !matches!(colon.token, Token::Colon) {
205 continue;
206 }
207 if colon.span.end != word.span.start {
211 continue;
212 }
213 let Token::Word(w) = &word.token else {
214 continue;
215 };
216 if w.quote_style.is_some() {
218 continue;
219 }
220 if !seen.insert(w.value.clone()) {
221 continue;
222 }
223 out.push(Placeholder {
224 name: w.value.clone(),
225 position: source_position(line_starts, colon.span.start, word.span.end),
226 });
227 }
228 out
229}
230
231fn ends_from_list(keyword: Keyword) -> bool {
236 matches!(
237 keyword,
238 Keyword::WHERE
239 | Keyword::GROUP
240 | Keyword::HAVING
241 | Keyword::ORDER
242 | Keyword::LIMIT
243 | Keyword::UNION
244 | Keyword::INTERSECT
245 | Keyword::EXCEPT
246 | Keyword::WINDOW
247 )
248}
249
250fn scan_tables(tokens: &[&TokenWithSpan], line_starts: &[usize]) -> Vec<TableRef> {
254 let cte_names = collect_cte_names(tokens);
255
256 let mut paren_depth: usize = 0;
257 let mut from_active: Vec<bool> = vec![false];
259 let mut seen = HashSet::new();
260 let mut out = Vec::new();
261
262 for (i, tok) in tokens.iter().enumerate() {
263 match &tok.token {
264 Token::LParen => {
265 paren_depth += 1;
266 from_active.push(false);
267 }
268 Token::RParen => {
269 if paren_depth > 0 {
270 paren_depth -= 1;
271 from_active.pop();
272 }
273 }
274 Token::Word(w) if w.keyword == Keyword::FROM => {
275 from_active[paren_depth] = true;
276 }
277 Token::Word(w) if ends_from_list(w.keyword) => {
278 from_active[paren_depth] = false;
279 }
280 Token::Word(w) if w.keyword == Keyword::NoKeyword => {
281 let is_candidate = i > 0
282 && match &tokens[i - 1].token {
283 Token::Word(pw) => {
284 pw.keyword == Keyword::FROM || pw.keyword == Keyword::JOIN
285 }
286 Token::Comma => from_active[paren_depth],
287 _ => false,
288 };
289 if !is_candidate {
290 continue;
291 }
292 let excluded = matches!(
296 tokens.get(i + 1).map(|t| &t.token),
297 Some(Token::LParen) | Some(Token::Period)
298 );
299 if excluded {
300 continue;
301 }
302 if cte_names.contains(&w.value.to_lowercase()) {
303 continue;
304 }
305 if !seen.insert(w.value.to_lowercase()) {
306 continue;
307 }
308 out.push(TableRef {
309 name: w.value.clone(),
310 position: source_position(line_starts, tok.span.start, tok.span.end),
311 });
312 }
313 _ => {}
314 }
315 }
316 out
317}
318
319fn collect_cte_names(tokens: &[&TokenWithSpan]) -> HashSet<String> {
329 let mut names = HashSet::new();
330 let mut i = 0usize;
331 while i < tokens.len() {
332 let is_with = matches!(&tokens[i].token, Token::Word(w) if w.keyword == Keyword::WITH);
333 if !is_with {
334 i += 1;
335 continue;
336 }
337 i += 1;
338 if matches!(tokens.get(i).map(|t| &t.token), Some(Token::Word(w)) if w.keyword == Keyword::RECURSIVE)
339 {
340 i += 1;
341 }
342 while let Some(Token::Word(name)) = tokens.get(i).map(|t| &t.token) {
343 names.insert(name.value.to_lowercase());
344 i += 1;
345 if matches!(tokens.get(i).map(|t| &t.token), Some(Token::LParen)) {
347 i = skip_balanced_parens(tokens, i);
348 }
349 let is_as = matches!(tokens.get(i).map(|t| &t.token), Some(Token::Word(w)) if w.keyword == Keyword::AS);
350 if !is_as {
351 break;
352 }
353 i += 1;
354 if !matches!(tokens.get(i).map(|t| &t.token), Some(Token::LParen)) {
355 break;
356 }
357 i = skip_balanced_parens(tokens, i);
358 if matches!(tokens.get(i).map(|t| &t.token), Some(Token::Comma)) {
359 i += 1;
360 continue; }
362 break;
363 }
364 }
365 names
366}
367
368fn skip_balanced_parens(tokens: &[&TokenWithSpan], open_paren_idx: usize) -> usize {
374 let mut depth = 0i32;
375 let mut i = open_paren_idx;
376 loop {
377 match tokens.get(i).map(|t| &t.token) {
378 Some(Token::LParen) => depth += 1,
379 Some(Token::RParen) => {
380 depth -= 1;
381 if depth == 0 {
382 return i + 1;
383 }
384 }
385 Some(_) => {}
386 None => return tokens.len(),
387 }
388 i += 1;
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn names(result: &ScanResult) -> Vec<&str> {
397 result.tables.iter().map(|t| t.name.as_str()).collect()
398 }
399
400 fn placeholder_names(result: &ScanResult) -> Vec<&str> {
401 result
402 .placeholders
403 .iter()
404 .map(|p| p.name.as_str())
405 .collect()
406 }
407
408 #[test]
409 fn simple_from_reports_table_and_position() {
410 let result = scan_sql("SELECT * FROM v").unwrap();
411 assert_eq!(names(&result), vec!["v"]);
412 assert_eq!(result.tables[0].position.line, 1);
413 assert_eq!(result.tables[0].position.column, 15);
414 assert_eq!(result.tables[0].position.offset, 14);
415 assert_eq!(result.tables[0].position.length, 1);
416 }
417
418 #[test]
419 fn join_variants_report_both_sides() {
420 let result = scan_sql("SELECT * FROM a LEFT JOIN b ON a.id = b.id").unwrap();
421 assert_eq!(names(&result), vec!["a", "b"]);
422 }
423
424 #[test]
425 fn comma_list_reports_every_item_ignoring_aliases() {
426 let result = scan_sql("SELECT * FROM a, b c, d AS e").unwrap();
427 assert_eq!(names(&result), vec!["a", "b", "d"]);
428 }
429
430 #[test]
431 fn cte_body_reported_but_cte_reference_excluded() {
432 let result = scan_sql("WITH cte AS (SELECT * FROM t) SELECT * FROM cte").unwrap();
433 assert_eq!(names(&result), vec!["t"]);
434 }
435
436 #[test]
437 fn subquery_in_from_reports_nothing() {
438 let result = scan_sql("SELECT * FROM (SELECT 1) sub").unwrap();
439 assert!(result.tables.is_empty());
440 }
441
442 #[test]
443 fn table_valued_function_reports_nothing() {
444 let result = scan_sql("SELECT * FROM json_each(x)").unwrap();
445 assert!(result.tables.is_empty());
446 }
447
448 #[test]
449 fn quoted_identifier_reports_unquoted_name() {
450 let result = scan_sql(r#"SELECT * FROM "Quoted""#).unwrap();
451 assert_eq!(names(&result), vec!["Quoted"]);
452 }
453
454 #[test]
455 fn qualified_name_reports_nothing() {
456 let result = scan_sql("SELECT * FROM main.t").unwrap();
457 assert!(result.tables.is_empty());
458 }
459
460 #[test]
461 fn dedup_is_case_insensitive_and_keeps_first_spelling() {
462 let result = scan_sql("SELECT * FROM v v2 JOIN V").unwrap();
463 assert_eq!(names(&result), vec!["v"]);
464 }
465
466 #[test]
467 fn placeholder_ignores_literal_and_comment_reports_real_one() {
468 let result = scan_sql("SELECT * WHERE x = ':notaparam' AND y = :ward -- :comment").unwrap();
469 assert_eq!(placeholder_names(&result), vec!["ward"]);
470 }
471
472 #[test]
473 fn double_colon_cast_is_not_a_placeholder() {
474 let result = scan_sql("SELECT x::int").unwrap();
475 assert!(result.placeholders.is_empty());
476 }
477
478 #[test]
479 fn placeholder_dedup_keeps_first_occurrence() {
480 let result = scan_sql("SELECT * WHERE a = :p AND b = :p").unwrap();
481 assert_eq!(result.placeholders.len(), 1);
482 }
483
484 #[test]
485 fn multibyte_text_offsets_count_chars_not_bytes() {
486 let sql = "SELECT * WHERE name = 'café' AND x = :p";
488 let result = scan_sql(sql).unwrap();
489 assert_eq!(placeholder_names(&result), vec!["p"]);
490 let pos = result.placeholders[0].position;
491 assert_eq!(pos.offset, sql.chars().count() - 2);
492 }
493
494 #[test]
495 fn multiline_sql_reports_correct_line_and_column() {
496 let sql = "SELECT *\nFROM v\nWHERE x = :p";
497 let result = scan_sql(sql).unwrap();
498 assert_eq!(result.tables[0].position.line, 2);
499 assert_eq!(result.tables[0].position.column, 6);
500 assert_eq!(result.placeholders[0].position.line, 3);
501 assert_eq!(result.placeholders[0].position.column, 11);
502 }
503
504 #[test]
505 fn tokenizer_failure_is_reported_as_scan_error() {
506 let err = scan_sql("SELECT 'unterminated").unwrap_err();
508 assert!(matches!(err, ScanError::Tokenize(_)));
509 }
510
511 #[test]
512 fn undeclared_tables_helper_is_case_insensitive_and_ordered() {
513 let result = scan_sql("SELECT * FROM a JOIN B JOIN c").unwrap();
514 let declared = vec!["A".to_string(), "c".to_string()];
515 let undeclared = undeclared_tables(&result, &declared);
516 assert_eq!(
517 undeclared
518 .iter()
519 .map(|t| t.name.as_str())
520 .collect::<Vec<_>>(),
521 vec!["B"]
522 );
523 }
524}