qail_core/analyzer/
scanner.rs

1//! Source code scanner for QAIL and SQL queries.
2
3use regex::Regex;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Type of query found in source code.
8#[derive(Debug, Clone, PartialEq)]
9pub enum QueryType {
10    /// Native QAIL query (get::, set::, del::, add::)
11    Qail,
12    /// Raw SQL query (SELECT, INSERT, UPDATE, DELETE)
13    RawSql,
14}
15
16/// A reference to a query in source code.
17#[derive(Debug, Clone)]
18pub struct CodeReference {
19    /// File path where the reference was found
20    pub file: PathBuf,
21    /// Line number (1-indexed)
22    pub line: usize,
23    /// Table name referenced
24    pub table: String,
25    /// Column names referenced (if any)
26    pub columns: Vec<String>,
27    /// Type of query
28    pub query_type: QueryType,
29    /// Code snippet containing the reference
30    pub snippet: String,
31}
32
33/// Scanner for finding QAIL and SQL references in source code.
34pub struct CodebaseScanner {
35    /// Regex patterns for QAIL queries
36    qail_action_pattern: Regex,
37    qail_column_pattern: Regex,
38    /// Regex patterns for SQL queries
39    sql_select_pattern: Regex,
40    sql_insert_pattern: Regex,
41    sql_update_pattern: Regex,
42    sql_delete_pattern: Regex,
43}
44
45impl Default for CodebaseScanner {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl CodebaseScanner {
52    /// Create a new scanner with default patterns.
53    pub fn new() -> Self {
54        Self {
55            // QAIL patterns: get::table, set::table, del::table, add::table
56            qail_action_pattern: Regex::new(r"(get|set|del|add)::(\w+)").unwrap(),
57            // QAIL column: 'column_name
58            qail_column_pattern: Regex::new(r"'(\w+)").unwrap(),
59            // SQL patterns
60            sql_select_pattern: Regex::new(r"(?i)SELECT\s+(.+?)\s+FROM\s+(\w+)").unwrap(),
61            sql_insert_pattern: Regex::new(r"(?i)INSERT\s+INTO\s+(\w+)").unwrap(),
62            sql_update_pattern: Regex::new(r"(?i)UPDATE\s+(\w+)\s+SET").unwrap(),
63            sql_delete_pattern: Regex::new(r"(?i)DELETE\s+FROM\s+(\w+)").unwrap(),
64        }
65    }
66
67    /// Scan a directory for all QAIL and SQL references.
68    pub fn scan(&self, path: &Path) -> Vec<CodeReference> {
69        let mut refs = Vec::new();
70
71        if path.is_file() {
72            if let Some(ext) = path.extension()
73                && (ext == "rs" || ext == "ts" || ext == "js" || ext == "py")
74            {
75                refs.extend(self.scan_file(path));
76            }
77        } else if path.is_dir() {
78            self.scan_dir_recursive(path, &mut refs);
79        }
80
81        refs
82    }
83
84    /// Recursively scan a directory.
85    fn scan_dir_recursive(&self, dir: &Path, refs: &mut Vec<CodeReference>) {
86        let entries = match fs::read_dir(dir) {
87            Ok(e) => e,
88            Err(_) => return,
89        };
90
91        for entry in entries.flatten() {
92            let path = entry.path();
93
94            // Skip common non-source directories
95            if path.is_dir() {
96                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
97                if name == "target"
98                    || name == "node_modules"
99                    || name == ".git"
100                    || name == "vendor"
101                    || name == "__pycache__"
102                {
103                    continue;
104                }
105                self.scan_dir_recursive(&path, refs);
106            } else if let Some(ext) = path.extension()
107                && (ext == "rs" || ext == "ts" || ext == "js" || ext == "py")
108            {
109                refs.extend(self.scan_file(&path));
110            }
111        }
112    }
113
114    /// Scan a single file for references.
115    fn scan_file(&self, path: &Path) -> Vec<CodeReference> {
116        let mut refs = Vec::new();
117
118        let content = match fs::read_to_string(path) {
119            Ok(c) => c,
120            Err(_) => return refs,
121        };
122
123        for (line_num, line) in content.lines().enumerate() {
124            let line_number = line_num + 1;
125
126            // Check for QAIL queries
127            for cap in self.qail_action_pattern.captures_iter(line) {
128                let action = cap.get(1).map(|m| m.as_str()).unwrap_or("");
129                let table = cap.get(2).map(|m| m.as_str()).unwrap_or("");
130
131                // Extract column references from the same line
132                let columns: Vec<String> = self
133                    .qail_column_pattern
134                    .captures_iter(line)
135                    .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
136                    .collect();
137
138                refs.push(CodeReference {
139                    file: path.to_path_buf(),
140                    line: line_number,
141                    table: table.to_string(),
142                    columns,
143                    query_type: QueryType::Qail,
144                    snippet: format!("{}::{}", action, table),
145                });
146            }
147
148            // Check for SQL SELECT
149            for cap in self.sql_select_pattern.captures_iter(line) {
150                let columns_str = cap.get(1).map(|m| m.as_str()).unwrap_or("");
151                let table = cap.get(2).map(|m| m.as_str()).unwrap_or("");
152
153                let columns = if columns_str.trim() == "*" {
154                    vec!["*".to_string()]
155                } else {
156                    columns_str
157                        .split(',')
158                        .map(|c| c.trim().to_string())
159                        .filter(|c| !c.is_empty())
160                        .collect()
161                };
162
163                refs.push(CodeReference {
164                    file: path.to_path_buf(),
165                    line: line_number,
166                    table: table.to_string(),
167                    columns,
168                    query_type: QueryType::RawSql,
169                    snippet: line.trim().chars().take(60).collect(),
170                });
171            }
172
173            // Check for SQL INSERT
174            for cap in self.sql_insert_pattern.captures_iter(line) {
175                let table = cap.get(1).map(|m| m.as_str()).unwrap_or("");
176                refs.push(CodeReference {
177                    file: path.to_path_buf(),
178                    line: line_number,
179                    table: table.to_string(),
180                    columns: vec![],
181                    query_type: QueryType::RawSql,
182                    snippet: line.trim().chars().take(60).collect(),
183                });
184            }
185
186            // Check for SQL UPDATE
187            for cap in self.sql_update_pattern.captures_iter(line) {
188                let table = cap.get(1).map(|m| m.as_str()).unwrap_or("");
189                refs.push(CodeReference {
190                    file: path.to_path_buf(),
191                    line: line_number,
192                    table: table.to_string(),
193                    columns: vec![],
194                    query_type: QueryType::RawSql,
195                    snippet: line.trim().chars().take(60).collect(),
196                });
197            }
198
199            // Check for SQL DELETE
200            for cap in self.sql_delete_pattern.captures_iter(line) {
201                let table = cap.get(1).map(|m| m.as_str()).unwrap_or("");
202                refs.push(CodeReference {
203                    file: path.to_path_buf(),
204                    line: line_number,
205                    table: table.to_string(),
206                    columns: vec![],
207                    query_type: QueryType::RawSql,
208                    snippet: line.trim().chars().take(60).collect(),
209                });
210            }
211        }
212
213        refs
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_qail_pattern() {
223        let scanner = CodebaseScanner::new();
224        let line = r#"let result = qail!("get::users:'name'email[id=$1]");"#;
225
226        assert!(scanner.qail_action_pattern.is_match(line));
227
228        let cap = scanner.qail_action_pattern.captures(line).unwrap();
229        assert_eq!(cap.get(1).unwrap().as_str(), "get");
230        assert_eq!(cap.get(2).unwrap().as_str(), "users");
231    }
232
233    #[test]
234    fn test_sql_select_pattern() {
235        let scanner = CodebaseScanner::new();
236        let line = r#"sqlx::query("SELECT name, email FROM users WHERE id = $1")"#;
237
238        assert!(scanner.sql_select_pattern.is_match(line));
239
240        let cap = scanner.sql_select_pattern.captures(line).unwrap();
241        assert_eq!(cap.get(2).unwrap().as_str(), "users");
242    }
243
244    #[test]
245    fn test_column_extraction() {
246        let scanner = CodebaseScanner::new();
247        let line = r#"get::users:'name'email'created_at"#;
248
249        let columns: Vec<String> = scanner
250            .qail_column_pattern
251            .captures_iter(line)
252            .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
253            .collect();
254
255        assert_eq!(columns, vec!["name", "email", "created_at"]);
256    }
257}