qail_core/analyzer/
scanner.rs1use regex::Regex;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq)]
9pub enum QueryType {
10 Qail,
12 RawSql,
14}
15
16#[derive(Debug, Clone)]
18pub struct CodeReference {
19 pub file: PathBuf,
21 pub line: usize,
23 pub table: String,
25 pub columns: Vec<String>,
27 pub query_type: QueryType,
29 pub snippet: String,
31}
32
33pub struct CodebaseScanner {
35 qail_action_pattern: Regex,
37 qail_column_pattern: Regex,
38 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 pub fn new() -> Self {
54 Self {
55 qail_action_pattern: Regex::new(r"(get|set|del|add)::(\w+)").unwrap(),
57 qail_column_pattern: Regex::new(r"'(\w+)").unwrap(),
59 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 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 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 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 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 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 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 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 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 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 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}