1use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug)]
9pub struct QailUsage {
10 pub file: String,
12 pub line: usize,
14 pub column: usize,
16 pub table: String,
18 pub is_dynamic_table: bool,
21 pub columns: Vec<String>,
23 pub action: String,
25 pub related_tables: Vec<String>,
29 pub is_cte_ref: bool,
31 pub has_rls: bool,
33 pub rls_policy_delegated: bool,
38 pub has_explicit_tenant_scope: bool,
41 pub file_uses_super_admin: bool,
45 pub scope_errors: Vec<String>,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54struct LiteralBindings {
55 scalars: HashMap<String, Vec<String>>,
56 arrays: HashMap<String, Vec<String>>,
57 typed_scalars: HashMap<String, Vec<String>>,
58 typed_arrays: HashMap<String, Vec<String>>,
59}
60
61#[derive(Debug, Clone, Default)]
62struct LiteralBindingIndex {
63 globals: LiteralBindings,
64 locals: Vec<ScopedLiteralBindings>,
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68struct ScopedLiteralBindings {
69 start: usize,
70 end: usize,
71 bindings: LiteralBindings,
72}
73
74#[derive(Debug, Clone)]
75struct CteAlias {
76 name: String,
77 start: usize,
78 end: usize,
79}
80
81struct AliasExtractionContext<'a> {
82 current_chain: &'a ScannedQailChain,
83 qail_bound_vars: &'a [(&'a str, &'a ScannedQailChain)],
84 source: &'a str,
85 local_functions: &'a [LocalFunction],
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89enum BindingStatementKind {
90 Let,
91 Const,
92}
93
94#[derive(Debug, Clone, Copy)]
95struct BindingStatement<'a> {
96 start: usize,
97 text: &'a str,
98 kind: BindingStatementKind,
99}
100
101#[derive(Debug, Clone)]
102struct LocalFunction {
103 name: String,
104 params: Vec<String>,
105 body_start: usize,
106 body_end: usize,
107}
108
109#[derive(Debug, Clone)]
110struct LocalFunctionCall {
111 name: String,
112 args: Vec<String>,
113 arg_spans: Vec<(usize, usize)>,
114 open_paren: usize,
115}
116
117#[derive(Debug, Clone, Default)]
118struct ParamSubstitutions {
119 values: HashMap<String, String>,
120 bindings: LiteralBindings,
121}
122
123pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
125 let mut usages = Vec::new();
126 scan_directory(Path::new(src_dir), &mut usages);
127 usages
128}
129
130pub fn scan_source_text(file: &str, content: &str) -> Vec<QailUsage> {
137 let mut usages = Vec::new();
138 scan_file_inner(file, content, &mut usages, false);
139 usages
140}
141
142pub fn source_uses_super_admin_without_allow(source: &str) -> bool {
149 !source_has_allow_comment(source, "qail:allow(super_admin)")
150 && source_has_associated_function_call(source, "SuperAdminToken", "for_system_process")
151}
152
153fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
154 if let Ok(entries) = fs::read_dir(dir) {
155 for entry in entries.flatten() {
156 let path = entry.path();
157 if path.is_dir() {
158 scan_directory(&path, usages);
159 } else if path.extension().is_some_and(|e| e == "rs")
160 && let Ok(content) = fs::read_to_string(&path)
161 {
162 scan_file(&path.display().to_string(), &content, usages);
163 }
164 }
165 }
166}
167
168fn collect_literal_binding_index(
169 content: &str,
170 local_functions: &[LocalFunction],
171) -> LiteralBindingIndex {
172 let mut index = LiteralBindingIndex::default();
173 let statements = collect_binding_statements(content);
174
175 for _ in 0..statements.len().max(1) {
176 let before = index.globals.clone();
177 for stmt in &statements {
178 if !matches!(stmt.kind, BindingStatementKind::Const)
179 || find_enclosing_local_function(stmt.start, local_functions).is_some()
180 {
181 continue;
182 }
183 let bindings = collect_const_statement_bindings(stmt.text, &index.globals);
184 if !literal_bindings_is_empty(&bindings) {
185 merge_literal_bindings(&mut index.globals, &bindings);
186 }
187 }
188 dedupe_literal_bindings(&mut index.globals);
189 if index.globals == before {
190 break;
191 }
192 }
193
194 index.locals.extend(collect_local_const_bindings(
195 content,
196 local_functions,
197 &statements,
198 &index.globals,
199 ));
200
201 for stmt in &statements {
202 let enclosing_function = find_enclosing_local_function(stmt.start, local_functions);
203 let visible_bindings = literal_bindings_for_offset(&index, stmt.start, enclosing_function);
204 match stmt.kind {
205 BindingStatementKind::Const => {
206 continue;
207 }
208 BindingStatementKind::Let => {
209 let bindings = collect_let_statement_bindings(stmt.text, &visible_bindings);
210 if !literal_bindings_is_empty(&bindings) {
211 index.locals.push(ScopedLiteralBindings {
212 start: stmt.start,
213 end: find_innermost_block_end(content, stmt.start).unwrap_or(content.len()),
214 bindings,
215 });
216 }
217 }
218 }
219 }
220
221 dedupe_literal_bindings(&mut index.globals);
222 index
223}
224
225fn collect_local_const_bindings(
226 content: &str,
227 local_functions: &[LocalFunction],
228 statements: &[BindingStatement<'_>],
229 globals: &LiteralBindings,
230) -> Vec<ScopedLiteralBindings> {
231 let const_statements = statements
232 .iter()
233 .filter_map(|stmt| {
234 if !matches!(stmt.kind, BindingStatementKind::Const)
235 || find_enclosing_local_function(stmt.start, local_functions).is_none()
236 {
237 return None;
238 }
239 find_innermost_block_span(content, stmt.start).map(|(start, end)| (start, end, stmt))
240 })
241 .collect::<Vec<_>>();
242 let mut scopes = Vec::new();
243 for _ in 0..const_statements.len().max(1) {
244 let before = scopes.clone();
245 for (start, end, stmt) in &const_statements {
246 let mut visible = globals.clone();
247 let mut visible_scopes = scopes
248 .iter()
249 .filter(|scope: &&ScopedLiteralBindings| {
250 scope.start <= stmt.start && stmt.start < scope.end
251 })
252 .collect::<Vec<_>>();
253 visible_scopes.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
254 for scope in visible_scopes {
255 merge_shadowing_literal_bindings(&mut visible, &scope.bindings);
256 }
257
258 let bindings = collect_const_statement_bindings(stmt.text, &visible);
259 if literal_bindings_is_empty(&bindings) {
260 continue;
261 }
262 if let Some(existing) = scopes
263 .iter_mut()
264 .find(|scope| scope.start == *start && scope.end == *end)
265 {
266 merge_literal_bindings(&mut existing.bindings, &bindings);
267 dedupe_literal_bindings(&mut existing.bindings);
268 } else {
269 scopes.push(ScopedLiteralBindings {
270 start: *start,
271 end: *end,
272 bindings,
273 });
274 }
275 }
276
277 sort_scoped_literal_bindings(&mut scopes);
278 if scopes == before {
279 break;
280 }
281 }
282
283 scopes
284}
285
286fn sort_scoped_literal_bindings(bindings: &mut [ScopedLiteralBindings]) {
287 bindings.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
288}
289
290fn literal_bindings_is_empty(bindings: &LiteralBindings) -> bool {
291 bindings.scalars.is_empty()
292 && bindings.arrays.is_empty()
293 && bindings.typed_scalars.is_empty()
294 && bindings.typed_arrays.is_empty()
295}
296
297fn literal_bindings_for_offset(
298 index: &LiteralBindingIndex,
299 offset: usize,
300 enclosing_function: Option<&LocalFunction>,
301) -> LiteralBindings {
302 let mut bindings = index.globals.clone();
303 for local in &index.locals {
304 if local.start >= offset || offset >= local.end {
305 continue;
306 }
307 let visible = match enclosing_function {
308 Some(function) => local.start >= function.body_start && local.start < function.body_end,
309 None => true,
310 };
311 if visible {
312 merge_shadowing_literal_bindings(&mut bindings, &local.bindings);
313 }
314 }
315 dedupe_binding_values(&mut bindings.scalars);
316 dedupe_binding_values(&mut bindings.arrays);
317 bindings
318}
319
320fn merge_shadowing_literal_bindings(target: &mut LiteralBindings, source: &LiteralBindings) {
321 let shadowed_names = source
322 .scalars
323 .keys()
324 .chain(source.arrays.keys())
325 .chain(source.typed_scalars.keys())
326 .chain(source.typed_arrays.keys())
327 .cloned()
328 .collect::<HashSet<_>>();
329
330 for name in shadowed_names {
331 target.scalars.remove(&name);
332 target.arrays.remove(&name);
333 target.typed_scalars.remove(&name);
334 target.typed_arrays.remove(&name);
335 }
336
337 merge_literal_bindings(target, source);
338}
339
340fn merge_literal_bindings(target: &mut LiteralBindings, source: &LiteralBindings) {
341 for (name, values) in &source.scalars {
342 target
343 .scalars
344 .entry(name.clone())
345 .or_default()
346 .extend(values.iter().cloned());
347 }
348 for (name, values) in &source.arrays {
349 target
350 .arrays
351 .entry(name.clone())
352 .or_default()
353 .extend(values.iter().cloned());
354 }
355 for (name, values) in &source.typed_scalars {
356 target
357 .typed_scalars
358 .entry(name.clone())
359 .or_default()
360 .extend(values.iter().cloned());
361 }
362 for (name, values) in &source.typed_arrays {
363 target
364 .typed_arrays
365 .entry(name.clone())
366 .or_default()
367 .extend(values.iter().cloned());
368 }
369}
370
371fn collect_binding_statements(content: &str) -> Vec<BindingStatement<'_>> {
372 let bytes = content.as_bytes();
373 let mut statements = Vec::new();
374 let mut i = 0usize;
375
376 while i < bytes.len() {
377 if starts_with_bytes(bytes, i, b"//") {
378 i += 2;
379 while i < bytes.len() && bytes[i] != b'\n' {
380 i += 1;
381 }
382 continue;
383 }
384 if starts_with_bytes(bytes, i, b"/*") {
385 i = consume_block_comment(bytes, i);
386 continue;
387 }
388 if let Some(next) = consume_rust_literal(bytes, i) {
389 i = next;
390 continue;
391 }
392
393 let kind = if starts_with_keyword(content, i, "let")
394 && !matches!(
395 previous_identifier_before(content, i).as_deref(),
396 Some("if" | "while")
397 ) {
398 Some(BindingStatementKind::Let)
399 } else if starts_with_keyword(content, i, "const")
400 || starts_with_keyword(content, i, "static")
401 {
402 Some(BindingStatementKind::Const)
403 } else {
404 None
405 };
406
407 if let Some(kind) = kind {
408 let end = find_statement_end(content, i).unwrap_or_else(|| line_end(content, i));
409 if let Some(text) = content.get(i..end) {
410 statements.push(BindingStatement {
411 start: i,
412 text,
413 kind,
414 });
415 }
416 i = end.max(i + 1);
417 continue;
418 }
419
420 i += 1;
421 }
422
423 statements
424}
425
426fn starts_with_keyword(source: &str, idx: usize, keyword: &str) -> bool {
427 let bytes = source.as_bytes();
428 let kw = keyword.as_bytes();
429 if !starts_with_bytes(bytes, idx, kw) {
430 return false;
431 }
432 let before_ok = idx == 0 || !is_ident_byte(bytes[idx - 1]);
433 let after = idx + kw.len();
434 let after_ok = after >= bytes.len() || !is_ident_byte(bytes[after]);
435 before_ok && after_ok
436}
437
438fn line_end(source: &str, start: usize) -> usize {
439 source
440 .get(start..)
441 .and_then(|tail| tail.find('\n').map(|idx| start + idx))
442 .unwrap_or(source.len())
443}
444
445fn collect_let_statement_bindings(
446 stmt: &str,
447 visible_bindings: &LiteralBindings,
448) -> LiteralBindings {
449 let mut bindings = LiteralBindings::default();
450 let line = stmt.trim();
451
452 if let Some(rest) = line.strip_prefix("let ") {
453 let rest = rest.trim();
454 if let Some((var, rhs)) = parse_simple_let(rest) {
455 let rhs = rhs.trim().trim_end_matches(';').trim();
456 let scalar_values = resolve_string_values(rhs, None, visible_bindings);
457 if !scalar_values.is_empty() {
458 bindings
459 .scalars
460 .entry(var.clone())
461 .or_default()
462 .extend(scalar_values);
463 }
464 let literals = extract_branch_literals(rhs, visible_bindings);
465 if !literals.is_empty() {
466 bindings
467 .scalars
468 .entry(var.clone())
469 .or_default()
470 .extend(literals);
471 }
472 let values = resolve_array_string_values(rhs, None, visible_bindings);
473 if !values.is_empty() {
474 bindings.arrays.insert(var.clone(), values);
475 }
476 if let Some(items) = extract_typed_column_binding_items(rhs, visible_bindings) {
477 bindings.typed_arrays.insert(var, items);
478 } else if direct_typed_column_expr_has_column(rhs) {
479 bindings
480 .typed_scalars
481 .entry(var)
482 .or_default()
483 .push(rhs.to_string());
484 } else if let Some(key) = binding_lookup_key(rhs) {
485 if let Some(items) = visible_bindings.typed_arrays.get(&key) {
486 bindings.typed_arrays.insert(var.clone(), items.clone());
487 }
488 if let Some(items) = visible_bindings.typed_scalars.get(&key) {
489 bindings
490 .typed_scalars
491 .entry(var)
492 .or_default()
493 .extend(items.iter().cloned());
494 }
495 }
496 }
497
498 if rest.starts_with('(')
499 && let Some(result) = parse_destructuring_let(line)
500 {
501 for (name, values) in result {
502 bindings.scalars.entry(name).or_default().extend(values);
503 }
504 }
505 }
506
507 dedupe_binding_values(&mut bindings.scalars);
508 dedupe_binding_values(&mut bindings.arrays);
509 dedupe_binding_values(&mut bindings.typed_scalars);
510 dedupe_binding_values(&mut bindings.typed_arrays);
511 bindings
512}
513
514fn collect_const_statement_bindings(
515 stmt: &str,
516 visible_bindings: &LiteralBindings,
517) -> LiteralBindings {
518 let mut bindings = LiteralBindings::default();
519 if let Some((name, rhs)) = parse_const_binding(stmt) {
520 let scalar_values = resolve_string_values(rhs, None, visible_bindings);
521 if !scalar_values.is_empty() {
522 bindings
523 .scalars
524 .entry(name.clone())
525 .or_default()
526 .extend(scalar_values);
527 }
528
529 let values = resolve_array_string_values(rhs, None, visible_bindings);
530 if !values.is_empty() {
531 bindings.arrays.insert(name.clone(), values);
532 }
533
534 if let Some(items) = extract_typed_column_binding_items(rhs, visible_bindings) {
535 bindings.typed_arrays.insert(name, items);
536 } else if direct_typed_column_expr_has_column(rhs) {
537 bindings
538 .typed_scalars
539 .entry(name)
540 .or_default()
541 .push(rhs.to_string());
542 } else if let Some(key) = binding_lookup_key(rhs) {
543 if let Some(items) = visible_bindings.typed_arrays.get(&key) {
544 bindings.typed_arrays.insert(name.clone(), items.clone());
545 }
546 if let Some(items) = visible_bindings.typed_scalars.get(&key) {
547 bindings
548 .typed_scalars
549 .entry(name)
550 .or_default()
551 .extend(items.iter().cloned());
552 }
553 }
554 }
555 dedupe_binding_values(&mut bindings.scalars);
556 dedupe_binding_values(&mut bindings.arrays);
557 dedupe_binding_values(&mut bindings.typed_scalars);
558 dedupe_binding_values(&mut bindings.typed_arrays);
559 bindings
560}
561
562fn parse_const_binding(stmt: &str) -> Option<(String, &str)> {
563 let mut rest = stmt.trim();
564
565 for _ in 0..4 {
566 let mut advanced = false;
567 for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "static "] {
568 if let Some(next) = rest.strip_prefix(prefix) {
569 rest = next.trim_start();
570 advanced = true;
571 }
572 }
573 if !advanced {
574 break;
575 }
576 }
577
578 if let Some(next) = rest.strip_prefix("mut ") {
579 rest = next.trim_start();
580 }
581
582 let name: String = rest
583 .chars()
584 .take_while(|c| c.is_alphanumeric() || *c == '_')
585 .collect();
586 if name.is_empty() {
587 return None;
588 }
589
590 let rest = rest[name.len()..].trim_start();
591 let rest = if rest.starts_with(':') {
592 rest.find('=').map(|pos| &rest[pos..])?
593 } else {
594 rest
595 };
596
597 let rhs = rest.strip_prefix('=')?.trim();
598 Some((name, rhs.trim_end_matches(';').trim()))
599}
600
601fn dedupe_binding_values(bindings: &mut HashMap<String, Vec<String>>) {
602 for values in bindings.values_mut() {
603 let mut seen = HashSet::new();
604 values.retain(|value| seen.insert(value.clone()));
605 }
606}
607
608fn dedupe_literal_bindings(bindings: &mut LiteralBindings) {
609 dedupe_binding_values(&mut bindings.scalars);
610 dedupe_binding_values(&mut bindings.arrays);
611 dedupe_binding_values(&mut bindings.typed_scalars);
612 dedupe_binding_values(&mut bindings.typed_arrays);
613}
614
615fn parse_simple_let(s: &str) -> Option<(String, &str)> {
618 let s = s.strip_prefix("mut ").unwrap_or(s).trim();
620 if s.starts_with('(') {
621 return None;
622 }
623
624 let ident: String = s
626 .chars()
627 .take_while(|c| c.is_alphanumeric() || *c == '_')
628 .collect();
629 if ident.is_empty() {
630 return None;
631 }
632
633 let rest = s[ident.len()..].trim_start();
635 let rest = if rest.starts_with(':') {
636 rest.find('=').map(|pos| &rest[pos..])?
638 } else {
639 rest
640 };
641
642 let rest = rest.strip_prefix('=')?.trim();
643 Some((ident, rest))
644}
645
646fn extract_branch_literals(expr: &str, visible_bindings: &LiteralBindings) -> Vec<String> {
650 let trimmed = expr.trim_start();
651
652 if trimmed.starts_with("match ") {
653 return extract_match_literal_arms(expr, visible_bindings);
654 }
655 if trimmed.starts_with("if ") {
656 return extract_if_scalar_blocks(trimmed, visible_bindings);
657 }
658
659 Vec::new()
660}
661
662fn extract_match_literal_arms(expr: &str, visible_bindings: &LiteralBindings) -> Vec<String> {
663 let trimmed = expr.trim_start();
664 if !trimmed.starts_with("match ") {
665 return Vec::new();
666 }
667
668 let Some(open) = find_first_code_byte(trimmed, b'{') else {
669 return Vec::new();
670 };
671 let Some(close) = find_matching_delim(trimmed, open, b'{', b'}') else {
672 return Vec::new();
673 };
674 let Some(body) = trimmed.get(open + 1..close) else {
675 return Vec::new();
676 };
677
678 let mut out = Vec::new();
679 for arm in split_top_level_args(body) {
680 let Some(arrow) = find_top_level_match_arrow(arm) else {
681 continue;
682 };
683 let result = arm.get(arrow + 2..).unwrap_or_default().trim();
684 out.extend(extract_branch_scalar_expr(result, visible_bindings));
685 }
686 dedupe_values(&mut out);
687 out
688}
689
690fn find_first_code_byte(source: &str, needle: u8) -> Option<usize> {
691 let bytes = source.as_bytes();
692 let mut i = 0usize;
693 while i < bytes.len() {
694 if starts_with_bytes(bytes, i, b"//") {
695 i += 2;
696 while i < bytes.len() && bytes[i] != b'\n' {
697 i += 1;
698 }
699 continue;
700 }
701 if starts_with_bytes(bytes, i, b"/*") {
702 i = consume_block_comment(bytes, i);
703 continue;
704 }
705 if let Some(next) = consume_rust_literal(bytes, i) {
706 i = next;
707 continue;
708 }
709 if bytes[i] == needle {
710 return Some(i);
711 }
712 i += 1;
713 }
714 None
715}
716
717fn find_top_level_match_arrow(source: &str) -> Option<usize> {
718 let bytes = source.as_bytes();
719 let mut i = 0usize;
720 let mut paren = 0usize;
721 let mut bracket = 0usize;
722 let mut brace = 0usize;
723
724 while i < bytes.len() {
725 if starts_with_bytes(bytes, i, b"//") {
726 i += 2;
727 while i < bytes.len() && bytes[i] != b'\n' {
728 i += 1;
729 }
730 continue;
731 }
732 if starts_with_bytes(bytes, i, b"/*") {
733 i = consume_block_comment(bytes, i);
734 continue;
735 }
736 if let Some(next) = consume_rust_literal(bytes, i) {
737 i = next;
738 continue;
739 }
740
741 match bytes[i] {
742 b'(' => paren += 1,
743 b')' => paren = paren.saturating_sub(1),
744 b'[' => bracket += 1,
745 b']' => bracket = bracket.saturating_sub(1),
746 b'{' => brace += 1,
747 b'}' => brace = brace.saturating_sub(1),
748 b'=' if paren == 0
749 && bracket == 0
750 && brace == 0
751 && bytes.get(i + 1).copied() == Some(b'>') =>
752 {
753 return Some(i);
754 }
755 _ => {}
756 }
757 i += 1;
758 }
759
760 None
761}
762
763fn extract_branch_scalar_expr(expr: &str, visible_bindings: &LiteralBindings) -> Vec<String> {
764 let Some(expr) = unwrap_single_block_expr(expr) else {
765 return Vec::new();
766 };
767 resolve_string_values(expr, None, visible_bindings)
768}
769
770fn extract_if_scalar_blocks(expr: &str, bindings: &LiteralBindings) -> Vec<String> {
771 let mut out = Vec::new();
772 let mut cursor = 0usize;
773
774 while cursor < expr.len() {
775 let Some(tail) = expr.get(cursor..) else {
776 break;
777 };
778 let Some(open_rel) = find_first_code_byte(tail, b'{') else {
779 break;
780 };
781 let open = cursor + open_rel;
782 let Some(close) = find_matching_delim(expr, open, b'{', b'}') else {
783 break;
784 };
785 if let Some(block) = expr.get(open + 1..close) {
786 out.extend(extract_branch_scalar_expr(block, bindings));
787 }
788 cursor = close + 1;
789 }
790
791 dedupe_values(&mut out);
792 out
793}
794
795fn unwrap_single_block_expr(mut expr: &str) -> Option<&str> {
796 expr = expr.trim().trim_end_matches(',').trim();
797 while expr.starts_with('{') {
798 let close = find_matching_delim(expr, 0, b'{', b'}')?;
799 if !expr.get(close + 1..)?.trim().is_empty() {
800 break;
801 }
802 expr = expr.get(1..close)?.trim();
803 }
804 Some(expr)
805}
806
807fn extract_branch_array_literals(expr: &str, bindings: &LiteralBindings) -> Vec<String> {
808 let trimmed = expr.trim_start();
809 let mut out = if trimmed.starts_with("match ") {
810 extract_match_array_arms(trimmed, bindings)
811 } else if trimmed.starts_with("if ") {
812 extract_if_array_blocks(trimmed, bindings)
813 } else {
814 Vec::new()
815 };
816 dedupe_values(&mut out);
817 out
818}
819
820fn extract_match_array_arms(expr: &str, bindings: &LiteralBindings) -> Vec<String> {
821 let Some(open) = find_first_code_byte(expr, b'{') else {
822 return Vec::new();
823 };
824 let Some(close) = find_matching_delim(expr, open, b'{', b'}') else {
825 return Vec::new();
826 };
827 let Some(body) = expr.get(open + 1..close) else {
828 return Vec::new();
829 };
830
831 let mut out = Vec::new();
832 for arm in split_top_level_args(body) {
833 let Some(arrow) = find_top_level_match_arrow(arm) else {
834 continue;
835 };
836 let result = arm.get(arrow + 2..).unwrap_or_default().trim();
837 out.extend(extract_array_literal_expr(result, bindings));
838 }
839 out
840}
841
842fn extract_if_array_blocks(expr: &str, bindings: &LiteralBindings) -> Vec<String> {
843 let mut out = Vec::new();
844 let mut cursor = 0usize;
845
846 while cursor < expr.len() {
847 let Some(tail) = expr.get(cursor..) else {
848 break;
849 };
850 let Some(open_rel) = find_first_code_byte(tail, b'{') else {
851 break;
852 };
853 let open = cursor + open_rel;
854 let Some(close) = find_matching_delim(expr, open, b'{', b'}') else {
855 break;
856 };
857 if let Some(block) = expr.get(open + 1..close) {
858 out.extend(extract_array_literal_expr(block, bindings));
859 }
860 cursor = close + 1;
861 }
862
863 out
864}
865
866fn extract_array_literal_expr(expr: &str, bindings: &LiteralBindings) -> Vec<String> {
867 let mut trimmed = expr.trim();
868 while let Some(rest) = trimmed.strip_prefix('&') {
869 trimmed = rest.trim_start();
870 }
871 trimmed = trimmed.trim_end_matches(',').trim();
872
873 if trimmed.starts_with('{') {
874 let Some(close) = find_matching_delim(trimmed, 0, b'{', b'}') else {
875 return Vec::new();
876 };
877 if !trimmed
878 .get(close + 1..)
879 .unwrap_or_default()
880 .trim()
881 .is_empty()
882 {
883 return Vec::new();
884 }
885 return trimmed
886 .get(1..close)
887 .map(|inner| extract_array_literal_expr(inner, bindings))
888 .unwrap_or_default();
889 }
890
891 if !trimmed.starts_with('[') {
892 return resolve_array_string_values(trimmed, None, bindings);
893 }
894 let Some(close) = find_matching_delim(trimmed, 0, b'[', b']') else {
895 return Vec::new();
896 };
897 if !trimmed
898 .get(close + 1..)
899 .unwrap_or_default()
900 .trim()
901 .is_empty()
902 {
903 return Vec::new();
904 }
905 trimmed
906 .get(1..close)
907 .map(collect_string_literals)
908 .unwrap_or_default()
909}
910
911fn parse_destructuring_let(line: &str) -> Option<Vec<(String, Vec<String>)>> {
914 let rest = line.strip_prefix("let ")?.trim();
916 let rest = rest.strip_prefix("mut ").unwrap_or(rest).trim();
917 let rest = rest.strip_prefix('(')?;
918
919 let close_paren = rest.find(')')?;
921 let names_str = &rest[..close_paren];
922 let names: Vec<String> = names_str
923 .split(',')
924 .map(|s| s.trim().to_string())
925 .filter(|s| !s.is_empty() && !s.starts_with('_'))
926 .collect();
927
928 if names.is_empty() {
929 return None;
930 }
931
932 let after_pattern = &rest[close_paren + 1..];
934 let eq_pos = after_pattern.find('=')?;
935 let rhs = after_pattern[eq_pos + 1..].trim();
936
937 if rhs.starts_with('(') {
939 let values = extract_tuple_literals(rhs);
940 if values.len() == names.len() {
941 return Some(
942 names
943 .into_iter()
944 .zip(values)
945 .map(|(n, v)| (n, vec![v]))
946 .collect(),
947 );
948 }
949 }
950
951 if rhs.starts_with("if ") {
953 let mut all_tuples: Vec<Vec<String>> = Vec::new();
954
955 let mut remaining = rhs;
957 while let Some(brace_pos) = remaining.find('{') {
958 let inside = &remaining[brace_pos + 1..];
959 if let Some(close_pos) = find_matching_brace(inside) {
960 let block = inside[..close_pos].trim();
961 if block.starts_with('(') {
963 let values = extract_tuple_literals(block);
964 if values.len() == names.len() {
965 all_tuples.push(values);
966 }
967 }
968 remaining = &inside[close_pos + 1..];
969 } else {
970 break;
971 }
972 }
973
974 if !all_tuples.is_empty() {
975 let mut result: Vec<(String, Vec<String>)> =
976 names.iter().map(|n| (n.clone(), Vec::new())).collect();
977
978 for tuple in &all_tuples {
979 for (i, val) in tuple.iter().enumerate() {
980 if i < result.len() {
981 result[i].1.push(val.clone());
982 }
983 }
984 }
985
986 return Some(result);
987 }
988 }
989
990 None
991}
992
993fn extract_tuple_literals(s: &str) -> Vec<String> {
995 let mut literals = Vec::new();
996 let s = s.trim();
997 let s = s.strip_prefix('(').unwrap_or(s);
998 let content = if let Some(pos) = s.rfind(')') {
1000 &s[..pos]
1001 } else {
1002 s.trim_end_matches(';').trim_end_matches(')')
1003 };
1004
1005 for part in content.split(',') {
1006 let part = part.trim();
1007 if let Some(lit) = extract_string_arg(part) {
1008 literals.push(lit);
1009 }
1010 }
1011 literals
1012}
1013
1014fn find_matching_brace(s: &str) -> Option<usize> {
1017 let mut depth = 0i32;
1018 for (i, ch) in s.chars().enumerate() {
1019 match ch {
1020 '{' => depth += 1,
1021 '}' => {
1022 if depth == 0 {
1023 return Some(i);
1024 }
1025 depth -= 1;
1026 }
1027 _ => {}
1028 }
1029 }
1030 None
1031}
1032
1033pub(crate) fn count_net_delimiters(line: &str) -> i32 {
1036 let mut depth: i32 = 0;
1037 let mut in_string = false;
1038 let mut prev = '\0';
1039 for ch in line.chars() {
1040 if ch == '"' && prev != '\\' {
1041 in_string = !in_string;
1042 } else if !in_string {
1043 match ch {
1044 '(' | '[' | '{' => depth += 1,
1045 ')' | ']' | '}' => depth -= 1,
1046 _ => {}
1047 }
1048 }
1049 prev = ch;
1050 }
1051 depth
1052}
1053
1054#[derive(Debug, Clone)]
1055struct ScannedQailChain {
1056 start: usize,
1057 end: usize,
1058 line: usize,
1059 column: usize,
1060 action: &'static str,
1061 first_arg: String,
1062 full_chain: String,
1063 bound_var: Option<String>,
1064}
1065
1066#[derive(Debug, Clone, Copy)]
1067struct QailConstructorHit {
1068 start: usize,
1069 action: &'static str,
1070 open_paren: usize,
1071 close_paren: usize,
1072 statement_end: usize,
1073}
1074
1075#[derive(Debug, Clone, Copy)]
1076struct MethodCall<'a> {
1077 name: &'a str,
1078 args: &'a str,
1079}
1080
1081#[derive(Debug, Clone, Copy)]
1082struct IdentMethodCall<'a> {
1083 args: &'a str,
1084 start: usize,
1085}
1086
1087fn collect_local_functions(source: &str) -> Vec<LocalFunction> {
1088 let bytes = source.as_bytes();
1089 let mut out = Vec::new();
1090 let mut i = 0usize;
1091
1092 while i < bytes.len() {
1093 if starts_with_bytes(bytes, i, b"//") {
1094 while i < bytes.len() && bytes[i] != b'\n' {
1095 i += 1;
1096 }
1097 continue;
1098 }
1099
1100 if starts_with_bytes(bytes, i, b"/*") {
1101 i = consume_block_comment(bytes, i);
1102 continue;
1103 }
1104
1105 if let Some(next) = consume_rust_literal(bytes, i) {
1106 i = next;
1107 continue;
1108 }
1109
1110 if !starts_with_bytes(bytes, i, b"fn")
1111 || i > 0 && is_ident_byte(bytes[i - 1])
1112 || bytes.get(i + 2).copied().is_some_and(is_ident_byte)
1113 {
1114 i += 1;
1115 continue;
1116 }
1117
1118 let name_start = skip_ws(bytes, i + 2);
1119 let Some((name, name_end)) = parse_ident_at_bytes(source, name_start) else {
1120 i += 2;
1121 continue;
1122 };
1123 let Some(open_paren) = parse_fn_params_open(source, name_end) else {
1124 i += 2;
1125 continue;
1126 };
1127 let Some(close_paren) = find_matching_delim(source, open_paren, b'(', b')') else {
1128 i += 2;
1129 continue;
1130 };
1131 let Some(body_start) = find_function_body_open(source, close_paren + 1) else {
1132 i = close_paren + 1;
1133 continue;
1134 };
1135 let Some(body_end) = find_matching_delim(source, body_start, b'{', b'}') else {
1136 i = body_start + 1;
1137 continue;
1138 };
1139
1140 out.push(LocalFunction {
1141 name: name.to_string(),
1142 params: parse_param_names(source.get(open_paren + 1..close_paren).unwrap_or_default()),
1143 body_start,
1144 body_end,
1145 });
1146
1147 i = close_paren + 1;
1148 }
1149
1150 out
1151}
1152
1153fn collect_local_function_calls(
1154 source: &str,
1155 functions: &[LocalFunction],
1156) -> Vec<LocalFunctionCall> {
1157 let mut out = Vec::new();
1158 let mut seen = HashSet::new();
1159 let bytes = source.as_bytes();
1160
1161 for function in functions {
1162 let needle = function.name.as_bytes();
1163 let mut idx = 0usize;
1164 while idx < bytes.len() {
1165 if starts_with_bytes(bytes, idx, b"//") {
1166 idx += 2;
1167 while idx < bytes.len() && bytes[idx] != b'\n' {
1168 idx += 1;
1169 }
1170 continue;
1171 }
1172 if starts_with_bytes(bytes, idx, b"/*") {
1173 idx = consume_block_comment(bytes, idx);
1174 continue;
1175 }
1176 if let Some(next) = consume_rust_literal(bytes, idx) {
1177 idx = next;
1178 continue;
1179 }
1180
1181 if !starts_with_bytes(bytes, idx, needle) {
1182 idx += 1;
1183 continue;
1184 }
1185
1186 if idx > 0
1187 && matches!(
1188 bytes[idx - 1],
1189 b'.' | b':' | b'!' | b'_' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z'
1190 )
1191 {
1192 idx += function.name.len();
1193 continue;
1194 }
1195 if bytes
1196 .get(idx + function.name.len())
1197 .copied()
1198 .is_some_and(is_ident_byte)
1199 {
1200 idx += function.name.len();
1201 continue;
1202 }
1203
1204 let Some(open_paren) =
1205 parse_call_open_paren_after_name(source, idx + function.name.len())
1206 else {
1207 idx += function.name.len();
1208 continue;
1209 };
1210 if previous_identifier_before(source, idx).as_deref() == Some("fn") {
1211 idx += function.name.len();
1212 continue;
1213 }
1214 let Some(close_paren) = find_matching_delim(source, open_paren, b'(', b')') else {
1215 idx = open_paren + 1;
1216 continue;
1217 };
1218 let parsed_args = source
1219 .get(open_paren + 1..close_paren)
1220 .map(|args| split_top_level_args_with_spans(args, open_paren + 1))
1221 .unwrap_or_default();
1222 let args = parsed_args
1223 .iter()
1224 .map(|(arg, _, _)| arg.clone())
1225 .collect::<Vec<_>>();
1226 let arg_spans = parsed_args
1227 .iter()
1228 .map(|(_, start, end)| (*start, *end))
1229 .collect::<Vec<_>>();
1230 let key = format!("{}@{}@{}", function.name, open_paren, close_paren);
1231 if seen.insert(key) {
1232 out.push(LocalFunctionCall {
1233 name: function.name.clone(),
1234 args,
1235 arg_spans,
1236 open_paren,
1237 });
1238 }
1239 idx = close_paren + 1;
1240 }
1241 }
1242
1243 out
1244}
1245
1246fn find_function_body_open(source: &str, start: usize) -> Option<usize> {
1247 let bytes = source.as_bytes();
1248 let mut i = start;
1249 while i < bytes.len() {
1250 if starts_with_bytes(bytes, i, b"//") {
1251 while i < bytes.len() && bytes[i] != b'\n' {
1252 i += 1;
1253 }
1254 continue;
1255 }
1256 if starts_with_bytes(bytes, i, b"/*") {
1257 i = consume_block_comment(bytes, i);
1258 continue;
1259 }
1260 if let Some(next) = consume_rust_literal(bytes, i) {
1261 i = next;
1262 continue;
1263 }
1264 match bytes[i] {
1265 b'{' => return Some(i),
1266 b';' => return None,
1267 _ => i += 1,
1268 }
1269 }
1270 None
1271}
1272
1273fn skip_optional_generics(source: &str, cursor: usize) -> Option<usize> {
1274 let bytes = source.as_bytes();
1275 let cursor = skip_ws(bytes, cursor);
1276 if bytes.get(cursor).copied() != Some(b'<') {
1277 return Some(cursor);
1278 }
1279 let end = find_matching_delim(source, cursor, b'<', b'>')?;
1280 Some(skip_ws(bytes, end + 1))
1281}
1282
1283fn parse_fn_params_open(source: &str, name_end: usize) -> Option<usize> {
1284 let bytes = source.as_bytes();
1285 let cursor = skip_optional_generics(source, name_end)?;
1286 (bytes.get(cursor).copied() == Some(b'(')).then_some(cursor)
1287}
1288
1289fn parse_call_open_paren_after_name(source: &str, name_end: usize) -> Option<usize> {
1290 let bytes = source.as_bytes();
1291 let mut cursor = skip_ws(bytes, name_end);
1292
1293 if starts_with_bytes(bytes, cursor, b"::") {
1294 cursor = skip_ws(bytes, cursor + 2);
1295 if bytes.get(cursor).copied() != Some(b'<') {
1296 return None;
1297 }
1298 cursor = skip_optional_generics(source, cursor)?;
1299 }
1300
1301 (bytes.get(cursor).copied() == Some(b'(')).then_some(cursor)
1302}
1303
1304fn parse_param_names(params: &str) -> Vec<String> {
1305 split_top_level_args(params)
1306 .into_iter()
1307 .filter_map(extract_param_name)
1308 .collect()
1309}
1310
1311fn extract_param_name(param: &str) -> Option<String> {
1312 let lhs = param.split(':').next()?.trim();
1313 if lhs.is_empty() {
1314 return None;
1315 }
1316 let lhs = lhs.strip_prefix("mut ").unwrap_or(lhs).trim();
1317 if matches!(lhs, "self" | "&self" | "&mut self" | "mut self") {
1318 return None;
1319 }
1320 extract_last_ident(lhs)
1321}
1322
1323fn extract_last_ident(text: &str) -> Option<String> {
1324 let bytes = text.as_bytes();
1325 let mut end = bytes.len();
1326 while end > 0 && !is_ident_byte(bytes[end - 1]) {
1327 end -= 1;
1328 }
1329 if end == 0 {
1330 return None;
1331 }
1332 let mut start = end;
1333 while start > 0 && is_ident_byte(bytes[start - 1]) {
1334 start -= 1;
1335 }
1336 let ident = text.get(start..end)?.trim();
1337 if ident.is_empty() {
1338 None
1339 } else {
1340 Some(ident.to_string())
1341 }
1342}
1343
1344fn previous_identifier_before(source: &str, start: usize) -> Option<String> {
1345 let bytes = source.as_bytes();
1346 let mut end = start;
1347 while end > 0 && bytes[end - 1].is_ascii_whitespace() {
1348 end -= 1;
1349 }
1350 if end == 0 {
1351 return None;
1352 }
1353
1354 let mut ident_end = end;
1355 while ident_end > 0 && !is_ident_byte(bytes[ident_end - 1]) {
1356 ident_end -= 1;
1357 }
1358 if ident_end == 0 {
1359 return None;
1360 }
1361
1362 let mut ident_start = ident_end;
1363 while ident_start > 0 && is_ident_byte(bytes[ident_start - 1]) {
1364 ident_start -= 1;
1365 }
1366 let ident = source.get(ident_start..ident_end)?.trim();
1367 if ident.is_empty() {
1368 None
1369 } else {
1370 Some(ident.to_string())
1371 }
1372}
1373
1374fn compute_line_starts(source: &str) -> Vec<usize> {
1375 let mut starts = Vec::with_capacity(source.lines().count() + 1);
1376 starts.push(0);
1377 for (idx, b) in source.bytes().enumerate() {
1378 if b == b'\n' {
1379 starts.push(idx + 1);
1380 }
1381 }
1382 starts
1383}
1384
1385fn offset_to_line_col(line_starts: &[usize], offset: usize) -> (usize, usize) {
1386 let idx = line_starts.partition_point(|&start| start <= offset);
1387 let line_idx = idx.saturating_sub(1);
1388 let line_start = line_starts.get(line_idx).copied().unwrap_or(0);
1389 (line_idx + 1, offset.saturating_sub(line_start))
1390}
1391
1392fn starts_with_bytes(haystack: &[u8], idx: usize, needle: &[u8]) -> bool {
1393 haystack
1394 .get(idx..idx.saturating_add(needle.len()))
1395 .is_some_and(|s| s == needle)
1396}
1397
1398fn skip_ws(bytes: &[u8], mut idx: usize) -> usize {
1399 while idx < bytes.len() && bytes[idx].is_ascii_whitespace() {
1400 idx += 1;
1401 }
1402 idx
1403}
1404
1405fn is_ident_byte(b: u8) -> bool {
1406 b.is_ascii_alphanumeric() || b == b'_'
1407}
1408
1409fn parse_ident_at_bytes(text: &str, start: usize) -> Option<(&str, usize)> {
1410 let bytes = text.as_bytes();
1411 let mut end = start;
1412 while end < bytes.len() && is_ident_byte(bytes[end]) {
1413 end += 1;
1414 }
1415 if end == start {
1416 None
1417 } else {
1418 Some((text.get(start..end)?, end))
1419 }
1420}
1421
1422fn consume_block_comment(bytes: &[u8], start: usize) -> usize {
1423 let mut i = start + 2;
1424 let mut depth = 1usize;
1425 while i < bytes.len() && depth > 0 {
1426 if starts_with_bytes(bytes, i, b"/*") {
1427 depth += 1;
1428 i += 2;
1429 } else if starts_with_bytes(bytes, i, b"*/") {
1430 depth = depth.saturating_sub(1);
1431 i += 2;
1432 } else {
1433 i += 1;
1434 }
1435 }
1436 i
1437}
1438
1439fn raw_string_prefix(bytes: &[u8], idx: usize) -> Option<(usize, usize, usize)> {
1440 if bytes.get(idx).copied() == Some(b'r') {
1441 let mut j = idx + 1;
1442 while bytes.get(j).copied() == Some(b'#') {
1443 j += 1;
1444 }
1445 if bytes.get(j).copied() == Some(b'"') {
1446 let hashes = j - (idx + 1);
1447 return Some((idx, j + 1, hashes));
1448 }
1449 return None;
1450 }
1451
1452 if bytes.get(idx).copied() == Some(b'b') && bytes.get(idx + 1).copied() == Some(b'r') {
1453 let mut j = idx + 2;
1454 while bytes.get(j).copied() == Some(b'#') {
1455 j += 1;
1456 }
1457 if bytes.get(j).copied() == Some(b'"') {
1458 let hashes = j - (idx + 2);
1459 return Some((idx, j + 1, hashes));
1460 }
1461 }
1462
1463 None
1464}
1465
1466fn find_raw_string_end(bytes: &[u8], mut idx: usize, hashes: usize) -> Option<usize> {
1467 while idx < bytes.len() {
1468 if bytes[idx] == b'"' {
1469 let mut ok = true;
1470 for off in 0..hashes {
1471 if bytes.get(idx + 1 + off).copied() != Some(b'#') {
1472 ok = false;
1473 break;
1474 }
1475 }
1476 if ok {
1477 return Some(idx);
1478 }
1479 }
1480 idx += 1;
1481 }
1482 None
1483}
1484
1485fn consume_rust_literal(bytes: &[u8], start: usize) -> Option<usize> {
1486 if let Some((_, content_start, hashes)) = raw_string_prefix(bytes, start) {
1487 let end_quote = find_raw_string_end(bytes, content_start, hashes)?;
1488 return Some(end_quote + 1 + hashes);
1489 }
1490
1491 if bytes.get(start).copied() == Some(b'"') || starts_with_bytes(bytes, start, b"b\"") {
1492 let quote_offset = if bytes.get(start).copied() == Some(b'"') {
1493 start
1494 } else {
1495 start + 1
1496 };
1497 let mut i = quote_offset + 1;
1498 while i < bytes.len() {
1499 if bytes[i] == b'\\' {
1500 i = (i + 2).min(bytes.len());
1501 continue;
1502 }
1503 if bytes[i] == b'"' {
1504 return Some(i + 1);
1505 }
1506 i += 1;
1507 }
1508 return Some(bytes.len());
1509 }
1510
1511 if bytes.get(start).copied() == Some(b'\'') {
1512 let mut i = start + 1;
1513 while i < bytes.len() {
1514 if bytes[i] == b'\\' {
1515 i = (i + 2).min(bytes.len());
1516 continue;
1517 }
1518 if bytes[i] == b'\'' {
1519 return Some(i + 1);
1520 }
1521 i += 1;
1522 }
1523 return Some(bytes.len());
1524 }
1525
1526 None
1527}
1528
1529fn find_matching_delim(source: &str, open_idx: usize, open: u8, close: u8) -> Option<usize> {
1530 let bytes = source.as_bytes();
1531 if bytes.get(open_idx).copied() != Some(open) {
1532 return None;
1533 }
1534
1535 let mut depth = 1usize;
1536 let mut i = open_idx + 1;
1537 while i < bytes.len() {
1538 if starts_with_bytes(bytes, i, b"//") {
1539 i += 2;
1540 while i < bytes.len() && bytes[i] != b'\n' {
1541 i += 1;
1542 }
1543 continue;
1544 }
1545 if starts_with_bytes(bytes, i, b"/*") {
1546 i = consume_block_comment(bytes, i);
1547 continue;
1548 }
1549 if let Some(next) = consume_rust_literal(bytes, i) {
1550 i = next;
1551 continue;
1552 }
1553
1554 if bytes[i] == open {
1555 depth += 1;
1556 i += 1;
1557 continue;
1558 }
1559 if bytes[i] == close {
1560 depth = depth.saturating_sub(1);
1561 if depth == 0 {
1562 return Some(i);
1563 }
1564 i += 1;
1565 continue;
1566 }
1567 i += 1;
1568 }
1569 None
1570}
1571
1572fn find_statement_end(source: &str, start: usize) -> Option<usize> {
1573 let bytes = source.as_bytes();
1574 let mut i = start;
1575 let mut paren = 0usize;
1576 let mut bracket = 0usize;
1577 let mut brace = 0usize;
1578
1579 while i < bytes.len() {
1580 if starts_with_bytes(bytes, i, b"//") {
1581 i += 2;
1582 while i < bytes.len() && bytes[i] != b'\n' {
1583 i += 1;
1584 }
1585 continue;
1586 }
1587 if starts_with_bytes(bytes, i, b"/*") {
1588 i = consume_block_comment(bytes, i);
1589 continue;
1590 }
1591 if let Some(next) = consume_rust_literal(bytes, i) {
1592 i = next;
1593 continue;
1594 }
1595
1596 if paren == 0 && bracket == 0 && brace == 0 && bytes[i] == b';' {
1597 return Some(i + 1);
1598 }
1599
1600 match bytes[i] {
1601 b'(' => paren += 1,
1602 b')' => paren = paren.saturating_sub(1),
1603 b'[' => bracket += 1,
1604 b']' => bracket = bracket.saturating_sub(1),
1605 b'{' => brace += 1,
1606 b'}' => brace = brace.saturating_sub(1),
1607 _ => {}
1608 }
1609 i += 1;
1610 }
1611 None
1612}
1613
1614fn find_statement_start(source: &str, end: usize) -> usize {
1615 let bytes = source.as_bytes();
1616 let mut i = end.min(bytes.len());
1617 while i > 0 {
1618 let prev = i - 1;
1619 match bytes[prev] {
1620 b';' | b'{' | b'}' => return i,
1621 _ => i -= 1,
1622 }
1623 }
1624 0
1625}
1626
1627fn find_next_qail_constructor(source: &str, start: usize) -> Option<QailConstructorHit> {
1628 let bytes = source.as_bytes();
1629 let mut i = start;
1630 while i < bytes.len() {
1631 if starts_with_bytes(bytes, i, b"//") {
1632 i += 2;
1633 while i < bytes.len() && bytes[i] != b'\n' {
1634 i += 1;
1635 }
1636 continue;
1637 }
1638 if starts_with_bytes(bytes, i, b"/*") {
1639 i = consume_block_comment(bytes, i);
1640 continue;
1641 }
1642 if let Some(next) = consume_rust_literal(bytes, i) {
1643 i = next;
1644 continue;
1645 }
1646
1647 if !starts_with_bytes(bytes, i, b"Qail::") {
1648 i += 1;
1649 continue;
1650 }
1651 if i > 0 && is_ident_byte(bytes[i - 1]) {
1652 i += "Qail::".len();
1653 continue;
1654 }
1655
1656 let name_start = i + "Qail::".len();
1657 let Some((method, mut cursor)) = parse_ident_at_bytes(source, name_start) else {
1658 i += "Qail::".len();
1659 continue;
1660 };
1661 let action = match method {
1662 "get" => "GET",
1663 "add" => "ADD",
1664 "set" => "SET",
1665 "del" => "DEL",
1666 "put" => "PUT",
1667 "merge_into" => "MERGE",
1668 "export" => "EXPORT",
1669 "truncate" => "TRUNCATE",
1670 "explain" => "EXPLAIN",
1671 "explain_analyze" => "EXPLAIN_ANALYZE",
1672 "lock" => "LOCK",
1673 "typed" => "TYPED",
1674 "raw_sql" => "RAW",
1675 _ => {
1676 i += "Qail::".len();
1677 continue;
1678 }
1679 };
1680
1681 cursor = skip_ws(bytes, cursor);
1682 if bytes.get(cursor).copied() != Some(b'(') {
1683 i += "Qail::".len();
1684 continue;
1685 }
1686
1687 let Some(close_paren) = find_matching_delim(source, cursor, b'(', b')') else {
1688 i = cursor + 1;
1689 continue;
1690 };
1691 let statement_end = find_qail_chain_end(source, close_paren);
1692 return Some(QailConstructorHit {
1693 start: i,
1694 action,
1695 open_paren: cursor,
1696 close_paren,
1697 statement_end,
1698 });
1699 }
1700 None
1701}
1702
1703fn find_qail_chain_end(source: &str, constructor_close_paren: usize) -> usize {
1704 let bytes = source.as_bytes();
1705 let mut cursor = constructor_close_paren + 1;
1706
1707 loop {
1708 cursor = skip_ws_and_comments(source, cursor);
1709 if bytes.get(cursor).copied() == Some(b'?') {
1710 cursor += 1;
1711 continue;
1712 }
1713 if bytes.get(cursor).copied() != Some(b'.') {
1714 return cursor;
1715 }
1716
1717 let name_start = skip_ws(bytes, cursor + 1);
1718 let Some((_, mut after_name)) = parse_ident_at_bytes(source, name_start) else {
1719 return cursor;
1720 };
1721 after_name = skip_ws(bytes, after_name);
1722 if starts_with_bytes(bytes, after_name, b"::") {
1723 after_name = skip_ws(bytes, after_name + 2);
1724 if bytes.get(after_name).copied() == Some(b'<') {
1725 let Some(angle_end) = find_matching_delim(source, after_name, b'<', b'>') else {
1726 return cursor;
1727 };
1728 after_name = skip_ws(bytes, angle_end + 1);
1729 }
1730 }
1731 if bytes.get(after_name).copied() != Some(b'(') {
1732 return cursor;
1733 }
1734 let Some(close) = find_matching_delim(source, after_name, b'(', b')') else {
1735 return cursor;
1736 };
1737 cursor = close + 1;
1738 }
1739}
1740
1741fn skip_ws_and_comments(source: &str, mut idx: usize) -> usize {
1742 let bytes = source.as_bytes();
1743 while idx < bytes.len() {
1744 if bytes[idx].is_ascii_whitespace() {
1745 idx += 1;
1746 continue;
1747 }
1748 if starts_with_bytes(bytes, idx, b"//") {
1749 idx += 2;
1750 while idx < bytes.len() && bytes[idx] != b'\n' {
1751 idx += 1;
1752 }
1753 continue;
1754 }
1755 if starts_with_bytes(bytes, idx, b"/*") {
1756 idx = consume_block_comment(bytes, idx);
1757 continue;
1758 }
1759 break;
1760 }
1761 idx
1762}
1763
1764fn extract_first_argument(args: &str) -> &str {
1765 let bytes = args.as_bytes();
1766 let mut i = 0usize;
1767 let mut paren = 0usize;
1768 let mut bracket = 0usize;
1769 let mut brace = 0usize;
1770
1771 while i < bytes.len() {
1772 if starts_with_bytes(bytes, i, b"//") {
1773 i += 2;
1774 while i < bytes.len() && bytes[i] != b'\n' {
1775 i += 1;
1776 }
1777 continue;
1778 }
1779 if starts_with_bytes(bytes, i, b"/*") {
1780 i = consume_block_comment(bytes, i);
1781 continue;
1782 }
1783 if let Some(next) = consume_rust_literal(bytes, i) {
1784 i = next;
1785 continue;
1786 }
1787
1788 match bytes[i] {
1789 b'(' => paren += 1,
1790 b')' => paren = paren.saturating_sub(1),
1791 b'[' => bracket += 1,
1792 b']' => bracket = bracket.saturating_sub(1),
1793 b'{' => brace += 1,
1794 b'}' => brace = brace.saturating_sub(1),
1795 b',' if paren == 0 && bracket == 0 && brace == 0 => {
1796 return args.get(..i).unwrap_or(args).trim();
1797 }
1798 _ => {}
1799 }
1800 i += 1;
1801 }
1802 args.trim()
1803}
1804
1805fn split_top_level_args(args: &str) -> Vec<&str> {
1806 let bytes = args.as_bytes();
1807 let mut out = Vec::new();
1808 let mut start = 0usize;
1809 let mut i = 0usize;
1810 let mut paren = 0usize;
1811 let mut bracket = 0usize;
1812 let mut brace = 0usize;
1813
1814 while i < bytes.len() {
1815 if starts_with_bytes(bytes, i, b"//") {
1816 i += 2;
1817 while i < bytes.len() && bytes[i] != b'\n' {
1818 i += 1;
1819 }
1820 continue;
1821 }
1822 if starts_with_bytes(bytes, i, b"/*") {
1823 i = consume_block_comment(bytes, i);
1824 continue;
1825 }
1826 if let Some(next) = consume_rust_literal(bytes, i) {
1827 i = next;
1828 continue;
1829 }
1830
1831 match bytes[i] {
1832 b'(' => paren += 1,
1833 b')' => paren = paren.saturating_sub(1),
1834 b'[' => bracket += 1,
1835 b']' => bracket = bracket.saturating_sub(1),
1836 b'{' => brace += 1,
1837 b'}' => brace = brace.saturating_sub(1),
1838 b',' if paren == 0 && bracket == 0 && brace == 0 => {
1839 if let Some(part) = args.get(start..i) {
1840 let part = part.trim();
1841 if !part.is_empty() {
1842 out.push(part);
1843 }
1844 }
1845 start = i + 1;
1846 }
1847 _ => {}
1848 }
1849 i += 1;
1850 }
1851
1852 if let Some(part) = args.get(start..) {
1853 let part = part.trim();
1854 if !part.is_empty() {
1855 out.push(part);
1856 }
1857 }
1858
1859 out
1860}
1861
1862fn split_top_level_args_with_spans(args: &str, base_offset: usize) -> Vec<(String, usize, usize)> {
1863 let bytes = args.as_bytes();
1864 let mut out = Vec::new();
1865 let mut start = 0usize;
1866 let mut i = 0usize;
1867 let mut paren = 0usize;
1868 let mut bracket = 0usize;
1869 let mut brace = 0usize;
1870
1871 while i < bytes.len() {
1872 if starts_with_bytes(bytes, i, b"//") {
1873 i += 2;
1874 while i < bytes.len() && bytes[i] != b'\n' {
1875 i += 1;
1876 }
1877 continue;
1878 }
1879 if starts_with_bytes(bytes, i, b"/*") {
1880 i = consume_block_comment(bytes, i);
1881 continue;
1882 }
1883 if let Some(next) = consume_rust_literal(bytes, i) {
1884 i = next;
1885 continue;
1886 }
1887
1888 match bytes[i] {
1889 b'(' => paren += 1,
1890 b')' => paren = paren.saturating_sub(1),
1891 b'[' => bracket += 1,
1892 b']' => bracket = bracket.saturating_sub(1),
1893 b'{' => brace += 1,
1894 b'}' => brace = brace.saturating_sub(1),
1895 b',' if paren == 0 && bracket == 0 && brace == 0 => {
1896 push_arg_span(args, base_offset, start, i, &mut out);
1897 start = i + 1;
1898 }
1899 _ => {}
1900 }
1901 i += 1;
1902 }
1903
1904 push_arg_span(args, base_offset, start, args.len(), &mut out);
1905 out
1906}
1907
1908fn push_arg_span(
1909 args: &str,
1910 base_offset: usize,
1911 start: usize,
1912 end: usize,
1913 out: &mut Vec<(String, usize, usize)>,
1914) {
1915 let Some((trimmed_start, trimmed_end)) = trim_span(args, start, end) else {
1916 return;
1917 };
1918 let Some(arg) = args.get(trimmed_start..trimmed_end) else {
1919 return;
1920 };
1921 out.push((
1922 arg.to_string(),
1923 base_offset + trimmed_start,
1924 base_offset + trimmed_end,
1925 ));
1926}
1927
1928fn trim_span(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1929 if start >= end || end > text.len() {
1930 return None;
1931 }
1932 let bytes = text.as_bytes();
1933 let mut trimmed_start = start;
1934 let mut trimmed_end = end;
1935 while trimmed_start < trimmed_end && bytes[trimmed_start].is_ascii_whitespace() {
1936 trimmed_start += 1;
1937 }
1938 while trimmed_end > trimmed_start && bytes[trimmed_end - 1].is_ascii_whitespace() {
1939 trimmed_end -= 1;
1940 }
1941 if trimmed_start >= trimmed_end {
1942 None
1943 } else {
1944 Some((trimmed_start, trimmed_end))
1945 }
1946}
1947
1948fn parse_string_literal_at(input: &str, start: usize) -> Option<(String, usize)> {
1949 let bytes = input.as_bytes();
1950 if start >= bytes.len() {
1951 return None;
1952 }
1953
1954 if let Some((_, content_start, hashes)) = raw_string_prefix(bytes, start) {
1955 let end_quote = find_raw_string_end(bytes, content_start, hashes)?;
1956 let lit = input.get(content_start..end_quote)?.to_string();
1957 return Some((lit, end_quote + 1 + hashes));
1958 }
1959
1960 let quote_offset = if bytes.get(start).copied() == Some(b'"') {
1961 start
1962 } else if starts_with_bytes(bytes, start, b"b\"") {
1963 start + 1
1964 } else {
1965 return None;
1966 };
1967
1968 let mut i = quote_offset + 1;
1969 while i < bytes.len() {
1970 if bytes[i] == b'\\' {
1971 i = (i + 2).min(bytes.len());
1972 continue;
1973 }
1974 if bytes[i] == b'"' {
1975 let raw = input.get(quote_offset + 1..i)?;
1976 return Some((unescape_rust_string(raw), i + 1));
1977 }
1978 i += 1;
1979 }
1980
1981 None
1982}
1983
1984fn unescape_rust_string(raw: &str) -> String {
1985 let mut out = String::with_capacity(raw.len());
1986 let mut chars = raw.chars();
1987 while let Some(ch) = chars.next() {
1988 if ch != '\\' {
1989 out.push(ch);
1990 continue;
1991 }
1992 match chars.next() {
1993 Some('n') => out.push('\n'),
1994 Some('r') => out.push('\r'),
1995 Some('t') => out.push('\t'),
1996 Some('0') => out.push('\0'),
1997 Some('\\') => out.push('\\'),
1998 Some('"') => out.push('"'),
1999 Some(other) => {
2000 out.push('\\');
2001 out.push(other);
2002 }
2003 None => out.push('\\'),
2004 }
2005 }
2006 out
2007}
2008
2009fn collect_string_literals(input: &str) -> Vec<String> {
2010 let bytes = input.as_bytes();
2011 let mut out = Vec::new();
2012 let mut i = 0usize;
2013
2014 while i < bytes.len() {
2015 if starts_with_bytes(bytes, i, b"//") {
2016 i += 2;
2017 while i < bytes.len() && bytes[i] != b'\n' {
2018 i += 1;
2019 }
2020 continue;
2021 }
2022 if starts_with_bytes(bytes, i, b"/*") {
2023 i = consume_block_comment(bytes, i);
2024 continue;
2025 }
2026
2027 if let Some((lit, next)) = parse_string_literal_at(input, i) {
2028 out.push(lit);
2029 i = next;
2030 continue;
2031 }
2032
2033 if let Some(next) = consume_rust_literal(bytes, i) {
2034 i = next;
2035 continue;
2036 }
2037
2038 i += 1;
2039 }
2040
2041 out
2042}
2043
2044fn extract_array_string_literals_from_expr(expr: &str) -> Vec<String> {
2045 let bytes = expr.as_bytes();
2046 let mut i = 0usize;
2047
2048 while i < bytes.len() {
2049 if starts_with_bytes(bytes, i, b"//") {
2050 i += 2;
2051 while i < bytes.len() && bytes[i] != b'\n' {
2052 i += 1;
2053 }
2054 continue;
2055 }
2056 if starts_with_bytes(bytes, i, b"/*") {
2057 i = consume_block_comment(bytes, i);
2058 continue;
2059 }
2060 if let Some(next) = consume_rust_literal(bytes, i) {
2061 i = next;
2062 continue;
2063 }
2064 if bytes[i] == b'['
2065 && let Some(end) = find_matching_delim(expr, i, b'[', b']')
2066 && let Some(inside) = expr.get(i + 1..end)
2067 {
2068 return collect_string_literals(inside);
2069 }
2070 i += 1;
2071 }
2072
2073 Vec::new()
2074}
2075
2076fn scan_chain_method_calls(chain: &str) -> Vec<MethodCall<'_>> {
2077 let bytes = chain.as_bytes();
2078 let mut out = Vec::new();
2079 let mut i = 0usize;
2080 let mut paren = 0usize;
2081 let mut bracket = 0usize;
2082 let mut brace = 0usize;
2083
2084 while i < bytes.len() {
2085 if starts_with_bytes(bytes, i, b"//") {
2086 i += 2;
2087 while i < bytes.len() && bytes[i] != b'\n' {
2088 i += 1;
2089 }
2090 continue;
2091 }
2092 if starts_with_bytes(bytes, i, b"/*") {
2093 i = consume_block_comment(bytes, i);
2094 continue;
2095 }
2096 if let Some(next) = consume_rust_literal(bytes, i) {
2097 i = next;
2098 continue;
2099 }
2100
2101 match bytes[i] {
2102 b'(' => {
2103 paren += 1;
2104 i += 1;
2105 continue;
2106 }
2107 b')' => {
2108 paren = paren.saturating_sub(1);
2109 i += 1;
2110 continue;
2111 }
2112 b'[' => {
2113 bracket += 1;
2114 i += 1;
2115 continue;
2116 }
2117 b']' => {
2118 bracket = bracket.saturating_sub(1);
2119 i += 1;
2120 continue;
2121 }
2122 b'{' => {
2123 brace += 1;
2124 i += 1;
2125 continue;
2126 }
2127 b'}' => {
2128 brace = brace.saturating_sub(1);
2129 i += 1;
2130 continue;
2131 }
2132 b'.' if paren == 0 && bracket == 0 && brace == 0 => {
2133 let name_start = skip_ws(bytes, i + 1);
2134 let Some((name, mut cursor)) = parse_ident_at_bytes(chain, name_start) else {
2135 i += 1;
2136 continue;
2137 };
2138 cursor = skip_ws(bytes, cursor);
2139
2140 if starts_with_bytes(bytes, cursor, b"::") {
2141 cursor = skip_ws(bytes, cursor + 2);
2142 if bytes.get(cursor).copied() == Some(b'<') {
2143 if let Some(angle_end) = find_matching_delim(chain, cursor, b'<', b'>') {
2144 cursor = skip_ws(bytes, angle_end + 1);
2145 } else {
2146 i += 1;
2147 continue;
2148 }
2149 }
2150 }
2151
2152 if bytes.get(cursor).copied() != Some(b'(') {
2153 i += 1;
2154 continue;
2155 }
2156
2157 let Some(close_idx) = find_matching_delim(chain, cursor, b'(', b')') else {
2158 i += 1;
2159 continue;
2160 };
2161
2162 if let Some(args) = chain.get(cursor + 1..close_idx) {
2163 out.push(MethodCall { name, args });
2164 }
2165 i = close_idx + 1;
2166 }
2167 _ => i += 1,
2168 }
2169 }
2170
2171 out
2172}
2173
2174fn scan_ident_method_calls<'a>(
2175 source: &'a str,
2176 ident: &str,
2177 method: &str,
2178) -> Vec<IdentMethodCall<'a>> {
2179 let bytes = source.as_bytes();
2180 let ident_bytes = ident.as_bytes();
2181 let mut calls = Vec::new();
2182 let mut i = 0usize;
2183
2184 while i < bytes.len() {
2185 if starts_with_bytes(bytes, i, b"//") {
2186 i += 2;
2187 while i < bytes.len() && bytes[i] != b'\n' {
2188 i += 1;
2189 }
2190 continue;
2191 }
2192 if starts_with_bytes(bytes, i, b"/*") {
2193 i = consume_block_comment(bytes, i);
2194 continue;
2195 }
2196 if let Some(next) = consume_rust_literal(bytes, i) {
2197 i = next;
2198 continue;
2199 }
2200
2201 if starts_with_bytes(bytes, i, ident_bytes)
2202 && !(i > 0 && is_ident_byte(bytes[i - 1]))
2203 && !bytes
2204 .get(i + ident_bytes.len())
2205 .copied()
2206 .is_some_and(is_ident_byte)
2207 {
2208 let after_ident = skip_ws(bytes, i + ident_bytes.len());
2209 if bytes.get(after_ident).copied() != Some(b'.') {
2210 i += 1;
2211 continue;
2212 }
2213 let method_start = skip_ws(bytes, after_ident + 1);
2214 if !starts_with_keyword(source, method_start, method) {
2215 i += 1;
2216 continue;
2217 }
2218 let after_method = skip_ws(bytes, method_start + method.len());
2219 if bytes.get(after_method).copied() != Some(b'(') {
2220 i += 1;
2221 continue;
2222 }
2223 let Some(close) = find_matching_delim(source, after_method, b'(', b')') else {
2224 i = after_method + 1;
2225 continue;
2226 };
2227 let args = source.get(after_method + 1..close).unwrap_or_default();
2228 calls.push(IdentMethodCall { args, start: i });
2229 i = close + 1;
2230 continue;
2231 }
2232
2233 i += 1;
2234 }
2235
2236 calls
2237}
2238
2239fn extract_to_cte_aliases(source: &str, bindings: &LiteralBindings) -> Vec<String> {
2240 let bytes = source.as_bytes();
2241 let mut aliases = Vec::new();
2242 let mut i = 0usize;
2243
2244 while i < bytes.len() {
2245 if starts_with_bytes(bytes, i, b"//") {
2246 i += 2;
2247 while i < bytes.len() && bytes[i] != b'\n' {
2248 i += 1;
2249 }
2250 continue;
2251 }
2252 if starts_with_bytes(bytes, i, b"/*") {
2253 i = consume_block_comment(bytes, i);
2254 continue;
2255 }
2256 if let Some(next) = consume_rust_literal(bytes, i) {
2257 i = next;
2258 continue;
2259 }
2260
2261 if bytes[i] != b'.' {
2262 i += 1;
2263 continue;
2264 }
2265 let name_start = skip_ws(bytes, i + 1);
2266 let Some((name, mut cursor)) = parse_ident_at_bytes(source, name_start) else {
2267 i += 1;
2268 continue;
2269 };
2270 if name != "to_cte" {
2271 i += 1;
2272 continue;
2273 }
2274 cursor = skip_ws(bytes, cursor);
2275 if bytes.get(cursor).copied() != Some(b'(') {
2276 i += 1;
2277 continue;
2278 }
2279 let Some(close) = find_matching_delim(source, cursor, b'(', b')') else {
2280 i = cursor + 1;
2281 continue;
2282 };
2283 if let Some(args) = source.get(cursor + 1..close) {
2284 aliases.extend(resolve_string_values(args, None, bindings));
2285 }
2286 i = close + 1;
2287 }
2288
2289 dedupe_values(&mut aliases);
2290 aliases
2291}
2292
2293fn extract_bound_var_from_prefix(prefix: &str) -> Option<String> {
2294 let mut s = prefix.trim_start();
2295 s = s.strip_prefix("let ")?;
2296 s = s.strip_prefix("mut ").unwrap_or(s).trim_start();
2297 if s.starts_with('(') {
2298 return None;
2299 }
2300
2301 let ident: String = s
2302 .chars()
2303 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
2304 .collect();
2305 if ident.is_empty() {
2306 return None;
2307 }
2308
2309 let rest = s[ident.len()..].trim_start();
2310 let rest = if rest.starts_with(':') {
2311 rest.find('=').map(|pos| &rest[pos..])?
2312 } else {
2313 rest
2314 };
2315 if !rest.trim_start().starts_with('=') {
2316 return None;
2317 }
2318
2319 Some(ident)
2320}
2321
2322fn extract_receiver_ident_before_dot(source: &str, dot_idx: usize) -> Option<String> {
2323 let bytes = source.as_bytes();
2324 if dot_idx == 0 || dot_idx > bytes.len() || bytes.get(dot_idx).copied() != Some(b'.') {
2325 return None;
2326 }
2327
2328 let mut end = dot_idx;
2329 while end > 0 && bytes[end - 1].is_ascii_whitespace() {
2330 end -= 1;
2331 }
2332 let mut start = end;
2333 while start > 0 && is_ident_byte(bytes[start - 1]) {
2334 start -= 1;
2335 }
2336 if start == end {
2337 return None;
2338 }
2339
2340 let mut before = start;
2341 while before > 0 && bytes[before - 1].is_ascii_whitespace() {
2342 before -= 1;
2343 }
2344 if before > 0 && matches!(bytes[before - 1], b'.' | b':') {
2345 return None;
2346 }
2347
2348 source.get(start..end).map(str::to_string)
2349}
2350
2351fn collect_execution_site_rls_offsets(source: &str) -> HashMap<String, Vec<usize>> {
2352 collect_execution_site_method_offsets(source, is_rls_method_name)
2353}
2354
2355fn is_rls_method_name(name: &str) -> bool {
2356 matches!(name, "with_rls" | "with_rls_policy" | "rls")
2357}
2358
2359fn is_rls_policy_method_name(name: &str) -> bool {
2360 name == "with_rls_policy"
2361}
2362
2363fn collect_execution_site_method_offsets(
2368 source: &str,
2369 accept: fn(&str) -> bool,
2370) -> HashMap<String, Vec<usize>> {
2371 let bytes = source.as_bytes();
2372 let mut out: HashMap<String, Vec<usize>> = HashMap::new();
2373 let mut i = 0usize;
2374
2375 while i < bytes.len() {
2376 if starts_with_bytes(bytes, i, b"//") {
2377 i += 2;
2378 while i < bytes.len() && bytes[i] != b'\n' {
2379 i += 1;
2380 }
2381 continue;
2382 }
2383 if starts_with_bytes(bytes, i, b"/*") {
2384 i = consume_block_comment(bytes, i);
2385 continue;
2386 }
2387 if let Some(next) = consume_rust_literal(bytes, i) {
2388 i = next;
2389 continue;
2390 }
2391
2392 if bytes[i] == b'.' {
2393 let name_start = skip_ws(bytes, i + 1);
2394 let Some((name, mut cursor)) = parse_ident_at_bytes(source, name_start) else {
2395 i += 1;
2396 continue;
2397 };
2398 if accept(name) {
2399 cursor = skip_ws(bytes, cursor);
2400 if bytes.get(cursor).copied() == Some(b'(')
2401 && let Some(var) = extract_receiver_ident_before_dot(source, i)
2402 {
2403 out.entry(var).or_default().push(i);
2404 }
2405 }
2406 i = cursor.max(i + 1);
2407 continue;
2408 }
2409
2410 i += 1;
2411 }
2412
2413 out
2414}
2415
2416fn source_has_allow_comment(source: &str, marker: &str) -> bool {
2417 let bytes = source.as_bytes();
2418 let mut i = 0usize;
2419
2420 while i < bytes.len() {
2421 if starts_with_bytes(bytes, i, b"//") {
2422 let start = i + 2;
2423 i += 2;
2424 while i < bytes.len() && bytes[i] != b'\n' {
2425 i += 1;
2426 }
2427 if let Some(comment) = source.get(start..i)
2428 && comment.contains(marker)
2429 {
2430 return true;
2431 }
2432 continue;
2433 }
2434
2435 if starts_with_bytes(bytes, i, b"/*") {
2436 let end = consume_block_comment(bytes, i);
2437 if let Some(comment) = source.get(i..end)
2438 && comment.contains(marker)
2439 {
2440 return true;
2441 }
2442 i = end;
2443 continue;
2444 }
2445
2446 if let Some(next) = consume_rust_literal(bytes, i) {
2447 i = next;
2448 continue;
2449 }
2450
2451 i += 1;
2452 }
2453
2454 false
2455}
2456
2457fn source_has_associated_function_call(source: &str, type_name: &str, fn_name: &str) -> bool {
2458 let bytes = source.as_bytes();
2459 let needle = fn_name.as_bytes();
2460 let mut i = 0usize;
2461
2462 while i < bytes.len() {
2463 if starts_with_bytes(bytes, i, b"//") {
2464 i += 2;
2465 while i < bytes.len() && bytes[i] != b'\n' {
2466 i += 1;
2467 }
2468 continue;
2469 }
2470 if starts_with_bytes(bytes, i, b"/*") {
2471 i = consume_block_comment(bytes, i);
2472 continue;
2473 }
2474 if let Some(next) = consume_rust_literal(bytes, i) {
2475 i = next;
2476 continue;
2477 }
2478
2479 if starts_with_bytes(bytes, i, needle)
2480 && !(i > 0 && is_ident_byte(bytes[i - 1]))
2481 && !bytes
2482 .get(i + needle.len())
2483 .copied()
2484 .is_some_and(is_ident_byte)
2485 {
2486 let after = skip_ws(bytes, i + needle.len());
2487 if bytes.get(after).copied() == Some(b'(')
2488 && associated_call_qualifier_before(source, i)
2489 .is_some_and(|qualifier| qualifier == type_name)
2490 {
2491 return true;
2492 }
2493 }
2494
2495 i += 1;
2496 }
2497
2498 false
2499}
2500
2501fn associated_call_qualifier_before(source: &str, fn_start: usize) -> Option<&str> {
2502 let bytes = source.as_bytes();
2503 let before_fn = skip_ws_back(bytes, fn_start);
2504 if before_fn < 2 || source.get(before_fn - 2..before_fn)? != "::" {
2505 return None;
2506 }
2507
2508 let before_colons = skip_ws_back(bytes, before_fn - 2);
2509 parse_ident_before(source, before_colons)
2510}
2511
2512fn skip_ws_back(bytes: &[u8], mut idx: usize) -> usize {
2513 while idx > 0 && bytes[idx - 1].is_ascii_whitespace() {
2514 idx -= 1;
2515 }
2516 idx
2517}
2518
2519fn parse_ident_before(source: &str, end: usize) -> Option<&str> {
2520 let bytes = source.as_bytes();
2521 let mut start = end;
2522 while start > 0 && is_ident_byte(bytes[start - 1]) {
2523 start -= 1;
2524 }
2525 if start == end {
2526 None
2527 } else {
2528 source.get(start..end)
2529 }
2530}
2531
2532fn collect_qail_chains(source: &str) -> Vec<ScannedQailChain> {
2533 let line_starts = compute_line_starts(source);
2534 let mut out = Vec::new();
2535 let mut cursor = 0usize;
2536
2537 while let Some(hit) = find_next_qail_constructor(source, cursor) {
2538 let statement_start = find_statement_start(source, hit.start);
2539 let args = source
2540 .get(hit.open_paren + 1..hit.close_paren)
2541 .unwrap_or_default();
2542 let first_arg = extract_first_argument(args).to_string();
2543 let full_chain = source.get(hit.start..hit.statement_end).unwrap_or_default();
2544 let bound_var = source
2545 .get(statement_start..hit.start)
2546 .and_then(extract_bound_var_from_prefix)
2547 .filter(|_| {
2548 source
2549 .get(statement_start..hit.start)
2550 .is_some_and(|prefix| !prefix.contains("Qail::"))
2551 });
2552 let (line, column0) = offset_to_line_col(&line_starts, hit.start);
2553 out.push(ScannedQailChain {
2554 start: hit.start,
2555 end: hit.statement_end,
2556 line,
2557 column: column0 + 1,
2558 action: hit.action,
2559 first_arg,
2560 full_chain: full_chain.to_string(),
2561 bound_var,
2562 });
2563
2564 let next = hit.start + "Qail::".len();
2565 if next <= cursor {
2566 cursor += 1;
2567 } else {
2568 cursor = next;
2569 }
2570 }
2571
2572 out
2573}
2574
2575fn collect_cte_aliases(
2576 chains: &[ScannedQailChain],
2577 source: &str,
2578 local_functions: &[LocalFunction],
2579 binding_index: &LiteralBindingIndex,
2580) -> Vec<CteAlias> {
2581 let mut aliases = Vec::new();
2582 let qail_bound_vars = chains
2583 .iter()
2584 .filter_map(|chain| chain.bound_var.as_ref().map(|var| (var.as_str(), chain)))
2585 .collect::<Vec<_>>();
2586
2587 for chain in chains {
2588 for call in scan_chain_method_calls(&chain.full_chain) {
2589 match call.name {
2590 "to_cte" => {
2591 let bindings = literal_bindings_for_offset(
2592 binding_index,
2593 chain.start,
2594 find_enclosing_local_function(chain.start, local_functions),
2595 );
2596 for name in resolve_string_values(call.args, None, &bindings) {
2597 push_cte_alias(&mut aliases, source, chain, name);
2598 }
2599 }
2600 "with" => {
2601 let args = split_top_level_args(call.args);
2602 if args.len() < 2 {
2603 continue;
2604 }
2605 if arg_starts_with_qail_constructor(args[1])
2606 || cte_arg_is_visible_bound_qail(
2607 args[1],
2608 chain,
2609 &qail_bound_vars,
2610 source,
2611 local_functions,
2612 )
2613 {
2614 let bindings = literal_bindings_for_offset(
2615 binding_index,
2616 chain.start,
2617 find_enclosing_local_function(chain.start, local_functions),
2618 );
2619 for alias in resolve_string_values(args[0], None, &bindings) {
2620 push_cte_alias(&mut aliases, source, chain, alias);
2621 }
2622 }
2623 }
2624 "with_cte" | "with_ctes" => {
2625 let bindings = literal_bindings_for_offset(
2626 binding_index,
2627 chain.start,
2628 find_enclosing_local_function(chain.start, local_functions),
2629 );
2630 for alias in extract_to_cte_aliases(call.args, &bindings) {
2631 push_cte_alias(&mut aliases, source, chain, alias);
2632 }
2633 }
2634 _ => {}
2635 }
2636 }
2637 }
2638
2639 for (idx, (var, source_chain)) in qail_bound_vars.iter().enumerate() {
2640 let scope_end =
2641 find_innermost_block_end(source, source_chain.start).unwrap_or(source.len());
2642 let next_same_var_start = qail_bound_vars
2643 .iter()
2644 .skip(idx + 1)
2645 .filter(|(other_var, _)| other_var == var)
2646 .map(|(_, other_chain)| other_chain.start)
2647 .next()
2648 .unwrap_or(scope_end);
2649
2650 for call in scan_ident_method_calls(source, var, "to_cte") {
2651 if call.start < source_chain.end
2652 || call.start >= next_same_var_start
2653 || call.start >= scope_end
2654 || !same_enclosing_function(source_chain.start, call.start, local_functions)
2655 {
2656 continue;
2657 }
2658 let bindings = literal_bindings_for_offset(
2659 binding_index,
2660 call.start,
2661 find_enclosing_local_function(call.start, local_functions),
2662 );
2663 for name in resolve_string_values(call.args, None, &bindings) {
2664 push_cte_alias_at(&mut aliases, source, call.start, name);
2665 }
2666 }
2667 }
2668 aliases
2669}
2670
2671fn arg_starts_with_qail_constructor(arg: &str) -> bool {
2672 let trimmed = arg.trim_start();
2673 if trimmed.starts_with("Qail::") {
2674 return true;
2675 }
2676 let Some(hit) = find_next_qail_constructor(trimmed, 0) else {
2677 return false;
2678 };
2679 let prefix = trimmed.get(..hit.start).unwrap_or_default().trim();
2680 prefix.is_empty() || prefix.ends_with("::")
2681}
2682
2683fn push_cte_alias(
2684 aliases: &mut Vec<CteAlias>,
2685 source: &str,
2686 chain: &ScannedQailChain,
2687 name: String,
2688) {
2689 push_cte_alias_at(aliases, source, chain.start, name);
2690}
2691
2692fn push_cte_alias_at(aliases: &mut Vec<CteAlias>, source: &str, start: usize, name: String) {
2693 let end = find_innermost_block_end(source, start).unwrap_or(source.len());
2694 if aliases
2695 .iter()
2696 .any(|alias| alias.name == name && alias.start == start && alias.end == end)
2697 {
2698 return;
2699 }
2700 aliases.push(CteAlias { name, start, end });
2701}
2702
2703fn cte_arg_is_visible_bound_qail(
2704 arg: &str,
2705 chain: &ScannedQailChain,
2706 qail_bound_vars: &[(&str, &ScannedQailChain)],
2707 source: &str,
2708 local_functions: &[LocalFunction],
2709) -> bool {
2710 let Some(key) = binding_lookup_key(arg) else {
2711 return false;
2712 };
2713 qail_bound_vars.iter().any(|(var, source_chain)| {
2714 *var == key
2715 && source_chain.start <= chain.start
2716 && chain.start
2717 < find_innermost_block_end(source, source_chain.start).unwrap_or(source.len())
2718 && same_enclosing_function(source_chain.start, chain.start, local_functions)
2719 })
2720}
2721
2722fn same_enclosing_function(a: usize, b: usize, functions: &[LocalFunction]) -> bool {
2723 let a_func =
2724 find_enclosing_local_function(a, functions).map(|func| (func.body_start, func.body_end));
2725 let b_func =
2726 find_enclosing_local_function(b, functions).map(|func| (func.body_start, func.body_end));
2727 a_func == b_func
2728}
2729
2730fn visible_cte_alias_names(aliases: &[CteAlias], offset: usize) -> HashSet<String> {
2731 aliases
2732 .iter()
2733 .filter(|alias| alias.start <= offset && offset < alias.end)
2734 .map(|alias| alias.name.clone())
2735 .collect()
2736}
2737
2738fn chain_defines_cte_alias(
2739 chain: &ScannedQailChain,
2740 table: &str,
2741 substitutions: Option<&ParamSubstitutions>,
2742 bindings: &LiteralBindings,
2743) -> bool {
2744 scan_chain_method_calls(&chain.full_chain)
2745 .into_iter()
2746 .filter(|call| call.name == "to_cte")
2747 .flat_map(|call| resolve_string_values(call.args, substitutions, bindings))
2748 .any(|alias| alias == table)
2749}
2750
2751fn find_enclosing_local_function(
2752 offset: usize,
2753 functions: &[LocalFunction],
2754) -> Option<&LocalFunction> {
2755 functions
2756 .iter()
2757 .filter(|func| offset > func.body_start && offset < func.body_end)
2758 .min_by_key(|func| func.body_end.saturating_sub(func.body_start))
2759}
2760
2761fn find_innermost_block_end(source: &str, offset: usize) -> Option<usize> {
2762 find_innermost_block_span(source, offset).map(|(_, end)| end)
2763}
2764
2765fn find_innermost_block_span(source: &str, offset: usize) -> Option<(usize, usize)> {
2766 let bytes = source.as_bytes();
2767 let mut stack = Vec::new();
2768 let mut i = 0usize;
2769 let limit = offset.min(bytes.len());
2770
2771 while i < limit {
2772 if starts_with_bytes(bytes, i, b"//") {
2773 i += 2;
2774 while i < limit && bytes[i] != b'\n' {
2775 i += 1;
2776 }
2777 continue;
2778 }
2779 if starts_with_bytes(bytes, i, b"/*") {
2780 i = consume_block_comment(bytes, i).min(limit);
2781 continue;
2782 }
2783 if let Some(next) = consume_rust_literal(bytes, i) {
2784 i = next.min(limit);
2785 continue;
2786 }
2787
2788 match bytes[i] {
2789 b'{' => stack.push(i),
2790 b'}' => {
2791 stack.pop();
2792 }
2793 _ => {}
2794 }
2795 i += 1;
2796 }
2797
2798 let open = *stack.last()?;
2799 let close = find_matching_delim(source, open, b'{', b'}')?;
2800 Some((open, close))
2801}
2802
2803fn build_param_substitutions(
2804 function: &LocalFunction,
2805 calls: &[LocalFunctionCall],
2806 function_name_counts: &HashMap<String, usize>,
2807 binding_index: &LiteralBindingIndex,
2808 local_functions: &[LocalFunction],
2809) -> Vec<ParamSubstitutions> {
2810 if function_name_counts
2811 .get(&function.name)
2812 .copied()
2813 .unwrap_or(0)
2814 != 1
2815 {
2816 return Vec::new();
2817 }
2818
2819 let mut out = Vec::new();
2820 for call in calls {
2821 if call.name != function.name || call.args.len() < function.params.len() {
2822 continue;
2823 }
2824 let values = function
2825 .params
2826 .iter()
2827 .cloned()
2828 .zip(call.args.iter().cloned())
2829 .collect::<HashMap<_, _>>();
2830 if !values.is_empty() {
2831 let caller_function = find_enclosing_local_function(call.open_paren, local_functions);
2832 let bindings =
2833 literal_bindings_for_offset(binding_index, call.open_paren, caller_function);
2834 out.push(ParamSubstitutions { values, bindings });
2835 }
2836 }
2837 out
2838}
2839
2840fn binding_lookup_key(expr: &str) -> Option<String> {
2841 let mut trimmed = expr.trim();
2842 while let Some(rest) = trimmed.strip_prefix('&') {
2843 trimmed = rest.trim_start();
2844 }
2845 trimmed = strip_identity_method_suffixes(trimmed);
2846 trimmed = trimmed.trim_matches(|ch: char| matches!(ch, '(' | ')' | '[' | ']'));
2847 while let Some(rest) = trimmed.strip_prefix('&') {
2848 trimmed = rest.trim_start();
2849 }
2850 let segment = trimmed.rsplit("::").next().unwrap_or(trimmed);
2851 let segment = segment.rsplit('.').next().unwrap_or(segment).trim();
2852 if segment.is_empty() || !segment.chars().all(|c| c.is_alphanumeric() || c == '_') {
2853 None
2854 } else {
2855 Some(segment.to_string())
2856 }
2857}
2858
2859fn strip_identity_method_suffixes(mut expr: &str) -> &str {
2860 loop {
2861 let trimmed = expr.trim_end();
2862 let Some(next) = [".clone()", ".as_ref()", ".as_str()"]
2863 .iter()
2864 .find_map(|suffix| trimmed.strip_suffix(suffix))
2865 else {
2866 return trimmed;
2867 };
2868 expr = next.trim_end();
2869 }
2870}
2871
2872fn resolve_string_values(
2873 expr: &str,
2874 substitutions: Option<&ParamSubstitutions>,
2875 bindings: &LiteralBindings,
2876) -> Vec<String> {
2877 let mut out = Vec::new();
2878 let mut visited = HashSet::new();
2879 resolve_string_values_inner(expr, substitutions, bindings, &mut visited, &mut out);
2880 dedupe_values(&mut out);
2881 out
2882}
2883
2884fn resolve_string_values_inner(
2885 expr: &str,
2886 substitutions: Option<&ParamSubstitutions>,
2887 bindings: &LiteralBindings,
2888 visited: &mut HashSet<String>,
2889 out: &mut Vec<String>,
2890) {
2891 if let Some(value) = extract_string_arg(expr) {
2892 out.push(value);
2893 return;
2894 }
2895
2896 let Some(key) = binding_lookup_key(expr) else {
2897 return;
2898 };
2899 let marker = format!("s:{key}");
2900 if !visited.insert(marker.clone()) {
2901 return;
2902 }
2903
2904 if let Some(substitutions) = substitutions
2905 && let Some(arg_expr) = substitutions.values.get(&key)
2906 {
2907 visited.remove(&marker);
2908 resolve_string_values_inner(arg_expr, None, &substitutions.bindings, visited, out);
2909 return;
2910 }
2911 if let Some(values) = bindings.scalars.get(&key) {
2912 out.extend(values.iter().cloned());
2913 }
2914}
2915
2916fn resolve_array_string_values(
2917 expr: &str,
2918 substitutions: Option<&ParamSubstitutions>,
2919 bindings: &LiteralBindings,
2920) -> Vec<String> {
2921 let mut out = Vec::new();
2922 let mut visited = HashSet::new();
2923 resolve_array_string_values_inner(expr, substitutions, bindings, &mut visited, &mut out);
2924 dedupe_values(&mut out);
2925 out
2926}
2927
2928fn resolve_array_string_values_inner(
2929 expr: &str,
2930 substitutions: Option<&ParamSubstitutions>,
2931 bindings: &LiteralBindings,
2932 visited: &mut HashSet<String>,
2933 out: &mut Vec<String>,
2934) {
2935 let branch = extract_branch_array_literals(expr, bindings);
2936 if !branch.is_empty() {
2937 out.extend(branch);
2938 return;
2939 }
2940
2941 let direct = extract_array_string_literals_from_expr(expr);
2942 if !direct.is_empty() {
2943 out.extend(direct);
2944 return;
2945 }
2946
2947 let Some(key) = binding_lookup_key(expr) else {
2948 return;
2949 };
2950 let marker = format!("a:{key}");
2951 if !visited.insert(marker.clone()) {
2952 return;
2953 }
2954
2955 if let Some(substitutions) = substitutions
2956 && let Some(arg_expr) = substitutions.values.get(&key)
2957 {
2958 visited.remove(&marker);
2959 resolve_array_string_values_inner(arg_expr, None, &substitutions.bindings, visited, out);
2960 return;
2961 }
2962 if let Some(values) = bindings.arrays.get(&key) {
2963 out.extend(values.iter().cloned());
2964 }
2965}
2966
2967fn dedupe_values(values: &mut Vec<String>) {
2968 let mut seen = HashSet::new();
2969 values.retain(|value| seen.insert(value.clone()));
2970}
2971
2972fn collect_helper_rls_param_indices(
2973 source: &str,
2974 functions: &[LocalFunction],
2975) -> HashMap<String, HashSet<usize>> {
2976 collect_helper_method_param_indices(source, functions, &["with_rls", "with_rls_policy", "rls"])
2977}
2978
2979fn collect_helper_method_param_indices(
2983 source: &str,
2984 functions: &[LocalFunction],
2985 methods: &[&str],
2986) -> HashMap<String, HashSet<usize>> {
2987 let mut out = HashMap::new();
2988
2989 for function in functions {
2990 let body = source
2991 .get(function.body_start + 1..function.body_end)
2992 .unwrap_or_default();
2993 let mut indices = HashSet::new();
2994 for (idx, param) in function.params.iter().enumerate() {
2995 if methods
2996 .iter()
2997 .any(|method| source_contains_ident_method_call(body, param, method))
2998 {
2999 indices.insert(idx);
3000 }
3001 }
3002 if !indices.is_empty() {
3003 out.insert(function.name.clone(), indices);
3004 }
3005 }
3006
3007 out
3008}
3009
3010fn source_contains_ident_method_call(source: &str, ident: &str, method: &str) -> bool {
3011 let needle = format!("{ident}.{method}");
3012 let bytes = source.as_bytes();
3013 let mut idx = 0usize;
3014 while idx < bytes.len() {
3015 if starts_with_bytes(bytes, idx, b"//") {
3016 idx += 2;
3017 while idx < bytes.len() && bytes[idx] != b'\n' {
3018 idx += 1;
3019 }
3020 continue;
3021 }
3022 if starts_with_bytes(bytes, idx, b"/*") {
3023 idx = consume_block_comment(bytes, idx);
3024 continue;
3025 }
3026 if let Some(next) = consume_rust_literal(bytes, idx) {
3027 idx = next;
3028 continue;
3029 }
3030 if !starts_with_bytes(bytes, idx, needle.as_bytes()) {
3031 idx += 1;
3032 continue;
3033 }
3034 let before_ok = idx == 0 || !is_ident_byte(source.as_bytes()[idx - 1]);
3035 if !before_ok {
3036 idx += needle.len();
3037 continue;
3038 }
3039 let mut after = idx + needle.len();
3040 after = skip_ws(source.as_bytes(), after);
3041 if source.as_bytes().get(after).copied() == Some(b'(') {
3042 return true;
3043 }
3044 idx += needle.len();
3045 }
3046 false
3047}
3048
3049fn chain_has_helper_param_rls(
3050 chain: &ScannedQailChain,
3051 calls: &[LocalFunctionCall],
3052 helper_rls_params: &HashMap<String, HashSet<usize>>,
3053 enclosing_function: Option<&LocalFunction>,
3054 next_same_var_start: usize,
3055) -> bool {
3056 for call in calls {
3057 let Some(rls_param_indices) = helper_rls_params.get(&call.name) else {
3058 continue;
3059 };
3060 if let Some(function) = enclosing_function
3061 && !(call.open_paren > function.body_start && call.open_paren < function.body_end)
3062 {
3063 continue;
3064 }
3065
3066 for (idx, (arg_start, arg_end)) in call.arg_spans.iter().enumerate() {
3067 if !rls_param_indices.contains(&idx) {
3068 continue;
3069 }
3070
3071 if chain.start >= *arg_start && chain.start < *arg_end {
3072 return true;
3073 }
3074
3075 if let Some(var) = chain.bound_var.as_ref()
3076 && call.open_paren >= chain.end
3077 && call.open_paren < next_same_var_start
3078 && let Some(arg_expr) = call.args.get(idx)
3079 && binding_lookup_key(arg_expr).as_deref() == Some(var.as_str())
3080 {
3081 return true;
3082 }
3083 }
3084 }
3085
3086 false
3087}
3088
3089pub(crate) fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
3090 scan_file_inner(file, content, usages, true);
3091}
3092
3093#[cfg(feature = "analyzer")]
3094pub(crate) fn scan_file_silent(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
3095 scan_file_inner(file, content, usages, false);
3096}
3097
3098fn scan_file_inner(file: &str, content: &str, usages: &mut Vec<QailUsage>, emit_warnings: bool) {
3099 let file_uses_super_admin = source_uses_super_admin_without_allow(content);
3103
3104 let chains = collect_qail_chains(content);
3105 let qail_bound_vars = chains
3106 .iter()
3107 .filter_map(|chain| chain.bound_var.as_ref().map(|var| (var.as_str(), chain)))
3108 .collect::<Vec<_>>();
3109 let execution_site_rls = collect_execution_site_rls_offsets(content);
3110 let execution_site_policy =
3111 collect_execution_site_method_offsets(content, is_rls_policy_method_name);
3112 let local_functions = collect_local_functions(content);
3113 let literal_binding_index = collect_literal_binding_index(content, &local_functions);
3114 let cte_aliases =
3115 collect_cte_aliases(&chains, content, &local_functions, &literal_binding_index);
3116 let local_function_calls = collect_local_function_calls(content, &local_functions);
3117 let helper_rls_params = collect_helper_rls_param_indices(content, &local_functions);
3118 let helper_policy_params =
3119 collect_helper_method_param_indices(content, &local_functions, &["with_rls_policy"]);
3120 let mut function_name_counts = HashMap::new();
3121 for function in &local_functions {
3122 *function_name_counts
3123 .entry(function.name.clone())
3124 .or_insert(0usize) += 1;
3125 }
3126
3127 for (idx, chain) in chains.iter().enumerate() {
3128 let action = chain.action;
3129
3130 if action == "RAW" {
3131 if emit_warnings {
3132 println!(
3133 "cargo:warning=QAIL: raw SQL at {}:{} — not schema-validated",
3134 file, chain.line
3135 );
3136 }
3137 continue;
3138 }
3139
3140 let enclosing_function = find_enclosing_local_function(chain.start, &local_functions);
3141 let next_same_var_start = chain.bound_var.as_ref().map(|var| {
3142 chains
3143 .iter()
3144 .skip(idx + 1)
3145 .find(|other| other.bound_var.as_ref() == Some(var))
3146 .map(|other| other.start)
3147 .unwrap_or(usize::MAX)
3148 });
3149 let late_site_applies = |sites: &HashMap<String, Vec<usize>>| {
3150 chain.bound_var.as_ref().is_some_and(|var| {
3151 sites.get(var).into_iter().flatten().any(|offset| {
3152 *offset >= chain.end
3153 && *offset < next_same_var_start.unwrap_or(usize::MAX)
3154 && match enclosing_function {
3155 Some(function) => {
3156 *offset > function.body_start && *offset < function.body_end
3157 }
3158 None => true,
3159 }
3160 })
3161 })
3162 };
3163 let has_late_rls = late_site_applies(&execution_site_rls);
3164 let has_helper_param_rls = chain_has_helper_param_rls(
3165 chain,
3166 &local_function_calls,
3167 &helper_rls_params,
3168 enclosing_function,
3169 next_same_var_start.unwrap_or(usize::MAX),
3170 );
3171 let has_rls = chain_has_rls(&chain.full_chain) || has_late_rls || has_helper_param_rls;
3172 let rls_policy_delegated = chain_has_rls_policy_delegation(&chain.full_chain)
3176 || late_site_applies(&execution_site_policy)
3177 || chain_has_helper_param_rls(
3178 chain,
3179 &local_function_calls,
3180 &helper_policy_params,
3181 enclosing_function,
3182 next_same_var_start.unwrap_or(usize::MAX),
3183 );
3184 let literal_bindings =
3185 literal_bindings_for_offset(&literal_binding_index, chain.start, enclosing_function);
3186 let substitution_contexts = enclosing_function
3187 .map(|function| {
3188 build_param_substitutions(
3189 function,
3190 &local_function_calls,
3191 &function_name_counts,
3192 &literal_binding_index,
3193 &local_functions,
3194 )
3195 })
3196 .unwrap_or_default();
3197
3198 let context_iter = if substitution_contexts.is_empty() {
3199 vec![None]
3200 } else {
3201 substitution_contexts.iter().map(Some).collect::<Vec<_>>()
3202 };
3203 let mut pushed = false;
3204 let mut seen_variants = HashSet::new();
3205 let visible_cte_names = visible_cte_alias_names(&cte_aliases, chain.start);
3206
3207 for substitutions in context_iter {
3208 let has_explicit_tenant_scope = chain_has_explicit_tenant_scope(
3209 action,
3210 &chain.full_chain,
3211 substitutions,
3212 &literal_bindings,
3213 );
3214 let resolved_tables = if action == "TYPED" {
3215 extract_typed_table_arg(&chain.first_arg)
3216 .into_iter()
3217 .collect::<Vec<_>>()
3218 } else {
3219 resolve_string_values(&chain.first_arg, substitutions, &literal_bindings)
3220 };
3221 if resolved_tables.is_empty() {
3222 continue;
3223 }
3224
3225 let raw_columns =
3226 extract_columns_with_bindings(&chain.full_chain, substitutions, &literal_bindings);
3227 let scope_checked_entries = extract_scope_checked_entries_with_bindings(
3228 &chain.full_chain,
3229 substitutions,
3230 &literal_bindings,
3231 );
3232 let scope_related_tables = extract_related_tables_with_bindings(
3233 &chain.full_chain,
3234 substitutions,
3235 &literal_bindings,
3236 );
3237 let related_tables = scope_related_tables
3238 .iter()
3239 .filter(|table| !visible_cte_names.contains(*table))
3240 .cloned()
3241 .collect::<Vec<_>>();
3242 let related_tables_key = related_tables.join("\x1d");
3243
3244 for table in resolved_tables {
3245 let alias_map = extract_table_aliases_with_bindings(
3246 &chain.full_chain,
3247 &table,
3248 substitutions,
3249 &literal_bindings,
3250 &AliasExtractionContext {
3251 current_chain: chain,
3252 qail_bound_vars: &qail_bound_vars,
3253 source: content,
3254 local_functions: &local_functions,
3255 },
3256 );
3257 let scope_qualifiers = extract_relation_scope_qualifiers_with_bindings(
3258 &chain.full_chain,
3259 &table,
3260 substitutions,
3261 &literal_bindings,
3262 );
3263 let columns = normalize_columns_with_aliases(&raw_columns, &alias_map);
3264 let columns_key = columns.join("\x1f");
3265 let is_cte_ref = visible_cte_names.contains(&table)
3266 && !chain_defines_cte_alias(chain, &table, substitutions, &literal_bindings);
3267 let scope_errors =
3268 qualifier_scope_errors(&scope_checked_entries, &table, &scope_qualifiers);
3269 let scope_errors_key = scope_errors.join("\x1c");
3270 let variant_key = format!(
3271 "{table}\x1e{columns_key}\x1e{related_tables_key}\x1e{scope_errors_key}"
3272 );
3273 if !seen_variants.insert(variant_key) {
3274 continue;
3275 }
3276 usages.push(QailUsage {
3277 file: file.to_string(),
3278 line: chain.line,
3279 column: chain.column,
3280 table,
3281 is_dynamic_table: false,
3282 columns: columns.clone(),
3283 action: action.to_string(),
3284 related_tables: related_tables.clone(),
3285 is_cte_ref,
3286 has_rls,
3287 rls_policy_delegated,
3288 has_explicit_tenant_scope,
3289 file_uses_super_admin,
3290 scope_errors,
3291 });
3292 pushed = true;
3293 }
3294 }
3295
3296 if !pushed && action != "TYPED" && emit_warnings {
3297 let var_hint = if chain.first_arg.trim().is_empty() {
3298 "?"
3299 } else {
3300 chain.first_arg.trim()
3301 };
3302 println!(
3303 "cargo:warning=Qail: dynamic table name `{}` in {}:{} — cannot validate columns at build time. Consider using string literals.",
3304 var_hint, file, chain.line
3305 );
3306 }
3307 }
3308}
3309
3310pub(crate) fn extract_string_arg(s: &str) -> Option<String> {
3311 let mut s = s.trim_start();
3312 while let Some(rest) = s.strip_prefix('&') {
3313 s = rest.trim_start();
3314 }
3315 let (lit, _) = parse_string_literal_at(s, 0)?;
3316 Some(lit)
3317}
3318
3319pub(crate) fn extract_typed_table_arg(s: &str) -> Option<String> {
3329 let s = s.trim();
3330 let ident: String = s
3332 .chars()
3333 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':' || *c == '#')
3334 .collect();
3335
3336 let segments: Vec<&str> = ident.split("::").filter(|s| !s.is_empty()).collect();
3337
3338 match segments.len() {
3339 0 => None,
3340 1 => normalize_typed_table_segment(segments[0]),
3341 _ => {
3342 let last = segments[segments.len() - 1];
3343 let table = if last == "table" {
3344 segments[segments.len() - 2]
3345 } else {
3346 last
3347 };
3348 normalize_typed_table_segment(table)
3349 }
3350 }
3351}
3352
3353fn normalize_typed_table_segment(segment: &str) -> Option<String> {
3354 let segment = segment.strip_prefix("r#").unwrap_or(segment);
3355 if segment.is_empty() || !segment.chars().all(|c| c.is_alphanumeric() || c == '_') {
3356 None
3357 } else {
3358 Some(segment.to_lowercase())
3359 }
3360}
3361
3362#[cfg(test)]
3363pub(crate) fn extract_columns(line: &str) -> Vec<String> {
3364 extract_columns_with_bindings(line, None, &LiteralBindings::default())
3365}
3366
3367fn extract_columns_with_bindings(
3368 line: &str,
3369 substitutions: Option<&ParamSubstitutions>,
3370 bindings: &LiteralBindings,
3371) -> Vec<String> {
3372 let calls = scan_chain_method_calls(line);
3373 let mut columns = Vec::new();
3374 let mut aliases = HashSet::new();
3375
3376 for call in &calls {
3377 if call.name == "alias" {
3378 for name in
3379 resolve_string_values(extract_first_argument(call.args), substitutions, bindings)
3380 {
3381 aliases.insert(name);
3382 }
3383 }
3384 }
3385
3386 for call in calls {
3387 match call.name {
3388 "column" => {
3389 for col in resolve_string_values(
3390 extract_first_argument(call.args),
3391 substitutions,
3392 bindings,
3393 ) {
3394 columns.push(col);
3395 }
3396 }
3397 "columns" => {
3398 columns.extend(resolve_array_string_values(
3399 extract_first_argument(call.args),
3400 substitutions,
3401 bindings,
3402 ));
3403 }
3404 "filter"
3405 | "or_filter"
3406 | "eq"
3407 | "ne"
3408 | "gt"
3409 | "lt"
3410 | "gte"
3411 | "lte"
3412 | "like"
3413 | "ilike"
3414 | "where_eq"
3415 | "order_by"
3416 | "order_desc"
3417 | "order_asc"
3418 | "in_vals"
3419 | "is_null"
3420 | "is_not_null"
3421 | "array_elem_contained_in_text" => {
3422 for col in resolve_string_values(
3423 extract_first_argument(call.args),
3424 substitutions,
3425 bindings,
3426 ) {
3427 columns.push(col);
3428 }
3429 }
3430 "typed_column" | "typed_eq" | "typed_ne" | "typed_gt" | "typed_lt" | "typed_gte"
3431 | "typed_lte" | "typed_filter" => {
3432 columns.extend(extract_typed_column_arg(
3433 call.args,
3434 0,
3435 substitutions,
3436 bindings,
3437 ));
3438 }
3439 "typed_columns" => {
3440 columns.extend(extract_typed_column_collection_arg(
3441 call.args,
3442 0,
3443 substitutions,
3444 bindings,
3445 ));
3446 }
3447 "set_value" | "set_opt" | "set_coalesce" | "set_coalesce_opt" => {
3448 for col in resolve_string_values(
3449 extract_first_argument(call.args),
3450 substitutions,
3451 bindings,
3452 ) {
3453 columns.push(col);
3454 }
3455 if let Some(value_arg) = split_top_level_args(call.args).get(1) {
3456 columns.extend(extract_value_reference_columns_with_bindings(
3457 value_arg,
3458 substitutions,
3459 bindings,
3460 ));
3461 }
3462 }
3463 "group_by" | "distinct_on" => {
3464 columns.extend(resolve_array_string_values(
3465 extract_first_argument(call.args),
3466 substitutions,
3467 bindings,
3468 ));
3469 }
3470 "filter_cond" | "having_cond" | "having_conds" | "merge_on_condition" => {
3471 columns.extend(extract_condition_columns_with_bindings(
3472 call.args,
3473 substitutions,
3474 bindings,
3475 ));
3476 }
3477 "select_expr" => {
3478 extract_expr_argument_columns_inner(
3479 call.args,
3480 substitutions,
3481 bindings,
3482 0,
3483 &mut columns,
3484 );
3485 }
3486 "column_expr" | "order_by_expr" => {
3487 columns.extend(extract_expression_columns_with_bindings(
3488 call.args,
3489 substitutions,
3490 bindings,
3491 ));
3492 }
3493 "columns_expr" | "select_exprs" | "distinct_on_expr" | "group_by_expr" => {
3494 extract_expr_collection_argument_columns_inner(
3495 call.args,
3496 substitutions,
3497 bindings,
3498 0,
3499 &mut columns,
3500 );
3501 columns.extend(extract_expression_columns_with_bindings(
3502 call.args,
3503 substitutions,
3504 bindings,
3505 ));
3506 }
3507 "returning" => {
3508 columns.extend(resolve_array_string_values(
3509 extract_first_argument(call.args),
3510 substitutions,
3511 bindings,
3512 ));
3513 }
3514 "on_conflict_nothing" => {
3515 for col in resolve_array_string_values(
3516 extract_first_argument(call.args),
3517 substitutions,
3518 bindings,
3519 ) {
3520 if !col.contains('.') {
3521 columns.push(col);
3522 }
3523 }
3524 }
3525 "on_conflict_update" => {
3526 columns.extend(resolve_array_string_arg(
3527 call.args,
3528 0,
3529 substitutions,
3530 bindings,
3531 ));
3532 columns.extend(resolve_array_string_arg(
3533 call.args,
3534 1,
3535 substitutions,
3536 bindings,
3537 ));
3538 }
3539 "merge_on_column" => {
3540 for col in resolve_string_values(
3541 extract_first_argument(call.args),
3542 substitutions,
3543 bindings,
3544 ) {
3545 columns.push(col);
3546 }
3547 columns.extend(resolve_string_arg(call.args, 2, substitutions, bindings));
3548 }
3549 "join" => {
3550 columns.extend(resolve_string_arg(call.args, 2, substitutions, bindings));
3551 columns.extend(resolve_string_arg(call.args, 3, substitutions, bindings));
3552 }
3553 "left_join" | "inner_join" => {
3554 columns.extend(resolve_string_arg(call.args, 1, substitutions, bindings));
3555 columns.extend(resolve_string_arg(call.args, 2, substitutions, bindings));
3556 }
3557 "left_join_as" | "inner_join_as" => {
3558 columns.extend(resolve_string_arg(call.args, 2, substitutions, bindings));
3559 columns.extend(resolve_string_arg(call.args, 3, substitutions, bindings));
3560 }
3561 "join_conds" | "left_join_conds" | "inner_join_conds" => {
3562 columns.extend(extract_condition_columns_with_bindings(
3563 call.args,
3564 substitutions,
3565 bindings,
3566 ));
3567 }
3568 "when_matched_update" | "when_not_matched_by_source_update" => {
3569 columns.extend(resolve_array_string_arg(
3570 call.args,
3571 0,
3572 substitutions,
3573 bindings,
3574 ));
3575 }
3576 "when_matched_update_if" => {
3577 if let Some(condition_arg) = split_top_level_args(call.args).first() {
3578 columns.extend(extract_condition_columns_with_bindings(
3579 condition_arg,
3580 substitutions,
3581 bindings,
3582 ));
3583 }
3584 columns.extend(resolve_array_string_arg(
3585 call.args,
3586 1,
3587 substitutions,
3588 bindings,
3589 ));
3590 }
3591 "when_not_matched_insert" => {
3592 let args = split_top_level_args(call.args);
3593 columns.extend(resolve_array_string_arg(
3594 call.args,
3595 0,
3596 substitutions,
3597 bindings,
3598 ));
3599 if let Some(values_arg) = args.get(1) {
3600 extract_expr_collection_argument_columns_inner(
3601 values_arg,
3602 substitutions,
3603 bindings,
3604 0,
3605 &mut columns,
3606 );
3607 }
3608 }
3609 "when_not_matched_insert_if" => {
3610 let args = split_top_level_args(call.args);
3611 if let Some(condition_arg) = args.first() {
3612 columns.extend(extract_condition_columns_with_bindings(
3613 condition_arg,
3614 substitutions,
3615 bindings,
3616 ));
3617 }
3618 columns.extend(resolve_array_string_arg(
3619 call.args,
3620 1,
3621 substitutions,
3622 bindings,
3623 ));
3624 if let Some(values_arg) = args.get(2) {
3625 extract_expr_collection_argument_columns_inner(
3626 values_arg,
3627 substitutions,
3628 bindings,
3629 0,
3630 &mut columns,
3631 );
3632 }
3633 }
3634 _ => {}
3635 }
3636 }
3637
3638 let columns: Vec<String> = columns
3642 .into_iter()
3643 .map(|col| {
3644 let col = if let Some(pos) = col.find(" as ").or_else(|| col.find(" AS ")) {
3646 col[..pos].trim().to_string()
3647 } else {
3648 col
3649 };
3650 if let Some(pos) = col.find("::") {
3652 col[..pos].to_string()
3653 } else {
3654 col
3655 }
3656 })
3657 .filter(|col| {
3658 !col.contains('(') && !col.contains(')') && !col.contains(' ')
3660 })
3661 .filter(|col| {
3662 !aliases.contains(col.as_str())
3664 })
3665 .filter(|col| !is_sql_pseudo_identifier(col))
3666 .collect();
3667
3668 columns
3669}
3670
3671fn is_sql_pseudo_identifier(col: &str) -> bool {
3672 if col.contains('.') {
3673 return false;
3674 }
3675 let normalized = col
3676 .trim()
3677 .trim_matches('"')
3678 .trim_matches('`')
3679 .to_ascii_uppercase();
3680 matches!(
3681 normalized.as_str(),
3682 "CURRENT_DATE"
3683 | "CURRENT_TIME"
3684 | "CURRENT_TIMESTAMP"
3685 | "LOCALTIME"
3686 | "LOCALTIMESTAMP"
3687 | "CURRENT_USER"
3688 | "SESSION_USER"
3689 | "CURRENT_ROLE"
3690 | "CURRENT_CATALOG"
3691 | "CURRENT_SCHEMA"
3692 | "USER"
3693 )
3694}
3695
3696fn extract_condition_columns_with_bindings(
3697 expr: &str,
3698 substitutions: Option<&ParamSubstitutions>,
3699 bindings: &LiteralBindings,
3700) -> Vec<String> {
3701 let mut columns = Vec::new();
3702 extract_condition_columns_inner(expr, substitutions, bindings, 0, &mut columns);
3703 dedupe_values(&mut columns);
3704 columns
3705}
3706
3707fn extract_condition_columns_inner(
3708 expr: &str,
3709 substitutions: Option<&ParamSubstitutions>,
3710 bindings: &LiteralBindings,
3711 depth: usize,
3712 columns: &mut Vec<String>,
3713) {
3714 if depth > 8 {
3715 return;
3716 }
3717 columns.extend(extract_condition_struct_left_columns(
3718 expr,
3719 substitutions,
3720 bindings,
3721 ));
3722 for call in scan_rust_function_calls(expr) {
3723 let args = split_top_level_args(call.args);
3724 if call.name == "cond" {
3725 if let Some(left_arg) = args.first() {
3726 columns.extend(extract_direct_expr_columns(
3727 left_arg,
3728 substitutions,
3729 bindings,
3730 ));
3731 }
3732 if let Some(value_arg) = args.get(2) {
3733 extract_expression_columns_inner(
3734 value_arg,
3735 substitutions,
3736 bindings,
3737 depth + 1,
3738 columns,
3739 );
3740 }
3741 } else if call.name == "recent" {
3742 columns.push("created_at".to_string());
3743 } else if is_condition_builder_name(call.name) {
3744 columns.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
3745 for value_arg in args.iter().skip(1) {
3746 extract_expression_columns_inner(
3747 value_arg,
3748 substitutions,
3749 bindings,
3750 depth + 1,
3751 columns,
3752 );
3753 }
3754 }
3755 if call.path.ends_with("Value::Column") {
3756 columns.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
3757 }
3758 extract_condition_columns_inner(call.args, substitutions, bindings, depth + 1, columns);
3759 }
3760}
3761
3762fn is_condition_builder_name(name: &str) -> bool {
3763 matches!(
3764 name,
3765 "eq" | "ne"
3766 | "gt"
3767 | "gte"
3768 | "lt"
3769 | "lte"
3770 | "is_in"
3771 | "not_in"
3772 | "is_null"
3773 | "is_not_null"
3774 | "like"
3775 | "ilike"
3776 | "not_like"
3777 | "between"
3778 | "not_between"
3779 | "regex"
3780 | "regex_i"
3781 | "contains"
3782 | "overlaps"
3783 | "similar_to"
3784 | "key_exists"
3785 | "recent_col"
3786 | "in_list"
3787 )
3788}
3789
3790fn extract_condition_struct_left_columns(
3791 expr: &str,
3792 substitutions: Option<&ParamSubstitutions>,
3793 bindings: &LiteralBindings,
3794) -> Vec<String> {
3795 let bytes = expr.as_bytes();
3796 let mut names = Vec::new();
3797 let mut i = 0usize;
3798
3799 while i < bytes.len() {
3800 if starts_with_bytes(bytes, i, b"//") {
3801 i += 2;
3802 while i < bytes.len() && bytes[i] != b'\n' {
3803 i += 1;
3804 }
3805 continue;
3806 }
3807 if starts_with_bytes(bytes, i, b"/*") {
3808 i = consume_block_comment(bytes, i);
3809 continue;
3810 }
3811 if let Some(next) = consume_rust_literal(bytes, i) {
3812 i = next;
3813 continue;
3814 }
3815
3816 if starts_with_keyword(expr, i, "Condition") {
3817 let after = skip_ws(bytes, i + "Condition".len());
3818 if bytes.get(after).copied() == Some(b'{')
3819 && let Some(close) = find_matching_delim(expr, after, b'{', b'}')
3820 && let Some(body) = expr.get(after + 1..close)
3821 {
3822 names.extend(resolve_struct_expr_column_field(
3823 body,
3824 "left",
3825 substitutions,
3826 bindings,
3827 ));
3828 names.extend(resolve_struct_expression_column_field(
3829 body,
3830 "value",
3831 substitutions,
3832 bindings,
3833 ));
3834 i = close + 1;
3835 continue;
3836 }
3837 }
3838
3839 i += 1;
3840 }
3841
3842 names
3843}
3844
3845fn resolve_struct_expression_column_field(
3846 body: &str,
3847 field: &str,
3848 substitutions: Option<&ParamSubstitutions>,
3849 bindings: &LiteralBindings,
3850) -> Vec<String> {
3851 let bytes = body.as_bytes();
3852 let mut values = Vec::new();
3853 let mut i = 0usize;
3854
3855 while i < bytes.len() {
3856 if starts_with_bytes(bytes, i, b"//") {
3857 i += 2;
3858 while i < bytes.len() && bytes[i] != b'\n' {
3859 i += 1;
3860 }
3861 continue;
3862 }
3863 if starts_with_bytes(bytes, i, b"/*") {
3864 i = consume_block_comment(bytes, i);
3865 continue;
3866 }
3867 if let Some(next) = consume_rust_literal(bytes, i) {
3868 i = next;
3869 continue;
3870 }
3871
3872 if starts_with_keyword(body, i, field) {
3873 let after_field = skip_ws(bytes, i + field.len());
3874 if bytes.get(after_field).copied() == Some(b':') {
3875 let field_expr = body.get(after_field + 1..).unwrap_or_default();
3876 extract_expression_columns_inner(
3877 extract_first_argument(field_expr),
3878 substitutions,
3879 bindings,
3880 0,
3881 &mut values,
3882 );
3883 i = after_field + 1;
3884 continue;
3885 }
3886 }
3887
3888 i += 1;
3889 }
3890
3891 values
3892}
3893
3894fn resolve_struct_expr_column_field(
3895 body: &str,
3896 field: &str,
3897 substitutions: Option<&ParamSubstitutions>,
3898 bindings: &LiteralBindings,
3899) -> Vec<String> {
3900 let bytes = body.as_bytes();
3901 let mut values = Vec::new();
3902 let mut i = 0usize;
3903
3904 while i < bytes.len() {
3905 if starts_with_bytes(bytes, i, b"//") {
3906 i += 2;
3907 while i < bytes.len() && bytes[i] != b'\n' {
3908 i += 1;
3909 }
3910 continue;
3911 }
3912 if starts_with_bytes(bytes, i, b"/*") {
3913 i = consume_block_comment(bytes, i);
3914 continue;
3915 }
3916 if let Some(next) = consume_rust_literal(bytes, i) {
3917 i = next;
3918 continue;
3919 }
3920
3921 if starts_with_keyword(body, i, field) {
3922 let after_field = skip_ws(bytes, i + field.len());
3923 if bytes.get(after_field).copied() == Some(b':') {
3924 let field_expr = body.get(after_field + 1..).unwrap_or_default();
3925 let field_expr = extract_first_argument(field_expr);
3926 values.extend(extract_direct_expr_columns(
3927 field_expr,
3928 substitutions,
3929 bindings,
3930 ));
3931 extract_expression_columns_inner(
3932 field_expr,
3933 substitutions,
3934 bindings,
3935 0,
3936 &mut values,
3937 );
3938 i = after_field + 1;
3939 continue;
3940 }
3941 }
3942
3943 i += 1;
3944 }
3945
3946 values
3947}
3948
3949fn resolve_struct_direct_expr_column_field(
3950 body: &str,
3951 field: &str,
3952 substitutions: Option<&ParamSubstitutions>,
3953 bindings: &LiteralBindings,
3954) -> Vec<String> {
3955 let bytes = body.as_bytes();
3956 let mut values = Vec::new();
3957 let mut i = 0usize;
3958
3959 while i < bytes.len() {
3960 if starts_with_bytes(bytes, i, b"//") {
3961 i += 2;
3962 while i < bytes.len() && bytes[i] != b'\n' {
3963 i += 1;
3964 }
3965 continue;
3966 }
3967 if starts_with_bytes(bytes, i, b"/*") {
3968 i = consume_block_comment(bytes, i);
3969 continue;
3970 }
3971 if let Some(next) = consume_rust_literal(bytes, i) {
3972 i = next;
3973 continue;
3974 }
3975
3976 if starts_with_keyword(body, i, field) {
3977 let after_field = skip_ws(bytes, i + field.len());
3978 if bytes.get(after_field).copied() == Some(b':') {
3979 let field_expr = body.get(after_field + 1..).unwrap_or_default();
3980 values.extend(extract_direct_expr_columns(
3981 extract_first_argument(field_expr),
3982 substitutions,
3983 bindings,
3984 ));
3985 i = after_field + 1;
3986 continue;
3987 }
3988 }
3989
3990 i += 1;
3991 }
3992
3993 values
3994}
3995
3996fn extract_direct_expr_columns(
3997 expr: &str,
3998 substitutions: Option<&ParamSubstitutions>,
3999 bindings: &LiteralBindings,
4000) -> Vec<String> {
4001 let mut columns = Vec::new();
4002 let trimmed = expr.trim();
4003 if trimmed.starts_with("Expr::Aliased") {
4004 columns.extend(extract_expr_aliased_names(trimmed, substitutions, bindings));
4005 }
4006 columns.extend(resolve_string_values(trimmed, substitutions, bindings));
4007 for call in scan_rust_function_calls(trimmed) {
4008 if !trimmed
4009 .get(..call.start)
4010 .unwrap_or_default()
4011 .trim()
4012 .is_empty()
4013 {
4014 continue;
4015 }
4016 if call.name == "col"
4017 || call.path.ends_with("Expr::Named")
4018 || call.path.ends_with("Value::Column")
4019 {
4020 columns.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
4021 }
4022 }
4023 dedupe_values(&mut columns);
4024 columns
4025}
4026
4027fn extract_expression_columns_with_bindings(
4028 expr: &str,
4029 substitutions: Option<&ParamSubstitutions>,
4030 bindings: &LiteralBindings,
4031) -> Vec<String> {
4032 let mut columns = Vec::new();
4033 extract_expression_columns_inner(expr, substitutions, bindings, 0, &mut columns);
4034 dedupe_values(&mut columns);
4035 columns
4036}
4037
4038fn extract_value_reference_columns_with_bindings(
4039 expr: &str,
4040 substitutions: Option<&ParamSubstitutions>,
4041 bindings: &LiteralBindings,
4042) -> Vec<String> {
4043 extract_expression_columns_with_bindings(expr, substitutions, bindings)
4044}
4045
4046fn extract_expression_columns_inner(
4047 expr: &str,
4048 substitutions: Option<&ParamSubstitutions>,
4049 bindings: &LiteralBindings,
4050 depth: usize,
4051 columns: &mut Vec<String>,
4052) {
4053 if depth > 8 {
4054 return;
4055 }
4056 columns.extend(extract_expr_aliased_names(expr, substitutions, bindings));
4057 columns.extend(extract_string_receiver_expression_columns(
4058 expr,
4059 substitutions,
4060 bindings,
4061 ));
4062 extract_expression_method_columns_inner(expr, substitutions, bindings, depth, columns);
4063 for call in scan_rust_function_calls(expr) {
4064 let args = split_top_level_args(call.args);
4065 match call.name {
4066 "percentage" => {
4067 columns.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
4068 columns.extend(resolve_string_arg(call.args, 1, substitutions, bindings));
4069 }
4070 "cast" => {
4071 if let Some(expr_arg) = args.first() {
4072 extract_expr_argument_columns_inner(
4073 expr_arg,
4074 substitutions,
4075 bindings,
4076 depth + 1,
4077 columns,
4078 );
4079 }
4080 }
4081 "binary" => {
4082 for index in [0, 2] {
4083 if let Some(expr_arg) = args.get(index) {
4084 extract_expr_argument_columns_inner(
4085 expr_arg,
4086 substitutions,
4087 bindings,
4088 depth + 1,
4089 columns,
4090 );
4091 }
4092 }
4093 }
4094 "add_expr" | "and_expr" | "or_expr" | "nullif" => {
4095 for expr_arg in args.iter().take(2) {
4096 extract_expr_argument_columns_inner(
4097 expr_arg,
4098 substitutions,
4099 bindings,
4100 depth + 1,
4101 columns,
4102 );
4103 }
4104 }
4105 "replace" => {
4106 for expr_arg in args.iter().take(3) {
4107 extract_expr_argument_columns_inner(
4108 expr_arg,
4109 substitutions,
4110 bindings,
4111 depth + 1,
4112 columns,
4113 );
4114 }
4115 }
4116 "coalesce" | "concat" => {
4117 if let Some(exprs_arg) = args.first() {
4118 extract_expr_collection_argument_columns_inner(
4119 exprs_arg,
4120 substitutions,
4121 bindings,
4122 depth + 1,
4123 columns,
4124 );
4125 }
4126 }
4127 "case_when" => {
4128 if let Some(condition_arg) = args.first() {
4129 columns.extend(extract_condition_columns_with_bindings(
4130 condition_arg,
4131 substitutions,
4132 bindings,
4133 ));
4134 }
4135 if let Some(then_arg) = args.get(1) {
4136 extract_expr_argument_columns_inner(
4137 then_arg,
4138 substitutions,
4139 bindings,
4140 depth + 1,
4141 columns,
4142 );
4143 }
4144 }
4145 _ => {}
4146 }
4147 if is_expression_string_arg_builder_name(call.name)
4148 || is_expression_column_builder_name(call.name)
4149 || is_condition_builder_name(call.name)
4150 || call.path.ends_with("Expr::Named")
4151 || call.path.ends_with("Value::Column")
4152 {
4153 columns.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
4154 }
4155 extract_expression_columns_inner(call.args, substitutions, bindings, depth + 1, columns);
4156 }
4157}
4158
4159fn extract_expression_method_columns_inner(
4160 expr: &str,
4161 substitutions: Option<&ParamSubstitutions>,
4162 bindings: &LiteralBindings,
4163 depth: usize,
4164 columns: &mut Vec<String>,
4165) {
4166 if depth > 8 {
4167 return;
4168 }
4169 for call in scan_chain_method_calls(expr) {
4170 match call.name {
4171 "when" => {
4172 let args = split_top_level_args(call.args);
4173 if let Some(condition_arg) = args.first() {
4174 columns.extend(extract_condition_columns_with_bindings(
4175 condition_arg,
4176 substitutions,
4177 bindings,
4178 ));
4179 }
4180 if let Some(then_arg) = args.get(1) {
4181 extract_expr_argument_columns_inner(
4182 then_arg,
4183 substitutions,
4184 bindings,
4185 depth + 1,
4186 columns,
4187 );
4188 }
4189 }
4190 "otherwise" => {
4191 extract_expr_argument_columns_inner(
4192 call.args,
4193 substitutions,
4194 bindings,
4195 depth + 1,
4196 columns,
4197 );
4198 }
4199 "filter" => {
4200 columns.extend(extract_condition_columns_with_bindings(
4201 call.args,
4202 substitutions,
4203 bindings,
4204 ));
4205 }
4206 "or_default" => {
4207 extract_expr_argument_columns_inner(
4208 call.args,
4209 substitutions,
4210 bindings,
4211 depth + 1,
4212 columns,
4213 );
4214 }
4215 _ => {}
4216 }
4217 }
4218}
4219
4220fn extract_string_receiver_expression_columns(
4221 expr: &str,
4222 substitutions: Option<&ParamSubstitutions>,
4223 bindings: &LiteralBindings,
4224) -> Vec<String> {
4225 let bytes = expr.as_bytes();
4226 let mut columns = Vec::new();
4227 let mut i = 0usize;
4228
4229 while i < bytes.len() {
4230 if starts_with_bytes(bytes, i, b"//") {
4231 i += 2;
4232 while i < bytes.len() && bytes[i] != b'\n' {
4233 i += 1;
4234 }
4235 continue;
4236 }
4237 if starts_with_bytes(bytes, i, b"/*") {
4238 i = consume_block_comment(bytes, i);
4239 continue;
4240 }
4241
4242 if let Some((lit, next)) = parse_string_literal_at(expr, i) {
4243 if expression_receiver_method_after(expr, next).is_some() {
4244 columns.push(lit);
4245 }
4246 i = next;
4247 continue;
4248 }
4249
4250 if is_ident_byte(bytes[i])
4251 && (i == 0 || !is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.')
4252 && let Some((name, name_end)) = parse_ident_at_bytes(expr, i)
4253 {
4254 if expression_receiver_method_after(expr, name_end).is_some() {
4255 columns.extend(resolve_string_values(name, substitutions, bindings));
4256 }
4257 i = name_end;
4258 continue;
4259 }
4260
4261 if let Some(next) = consume_rust_literal(bytes, i) {
4262 i = next;
4263 continue;
4264 }
4265
4266 i += 1;
4267 }
4268
4269 columns
4270}
4271
4272fn expression_receiver_method_after(expr: &str, receiver_end: usize) -> Option<&str> {
4273 let bytes = expr.as_bytes();
4274 let dot = skip_ws(bytes, receiver_end);
4275 if bytes.get(dot).copied() != Some(b'.') {
4276 return None;
4277 }
4278 let name_start = skip_ws(bytes, dot + 1);
4279 let (name, name_end) = parse_ident_at_bytes(expr, name_start)?;
4280 if !is_expression_receiver_method_name(name) {
4281 return None;
4282 }
4283 let args_start = skip_ws(bytes, name_end);
4284 (bytes.get(args_start).copied() == Some(b'(')).then_some(name)
4285}
4286
4287fn is_expression_receiver_method_name(name: &str) -> bool {
4288 matches!(
4289 name,
4290 "with_alias"
4291 | "or_default"
4292 | "json"
4293 | "path"
4294 | "cast"
4295 | "upper"
4296 | "lower"
4297 | "trim"
4298 | "length"
4299 | "abs"
4300 )
4301}
4302
4303fn extract_expr_argument_columns_inner(
4304 expr: &str,
4305 substitutions: Option<&ParamSubstitutions>,
4306 bindings: &LiteralBindings,
4307 depth: usize,
4308 columns: &mut Vec<String>,
4309) {
4310 if depth > 8 {
4311 return;
4312 }
4313 columns.extend(resolve_string_values(expr, substitutions, bindings));
4314 extract_expression_columns_inner(expr, substitutions, bindings, depth + 1, columns);
4315}
4316
4317fn extract_expr_collection_argument_columns_inner(
4318 expr: &str,
4319 substitutions: Option<&ParamSubstitutions>,
4320 bindings: &LiteralBindings,
4321 depth: usize,
4322 columns: &mut Vec<String>,
4323) {
4324 if depth > 8 {
4325 return;
4326 }
4327 let Some(inner) = extract_direct_expr_collection_inner(expr) else {
4328 extract_expression_columns_inner(expr, substitutions, bindings, depth + 1, columns);
4329 return;
4330 };
4331 for expr_arg in split_top_level_args(inner) {
4332 extract_expr_argument_columns_inner(expr_arg, substitutions, bindings, depth + 1, columns);
4333 }
4334}
4335
4336fn extract_direct_expr_collection_inner(expr: &str) -> Option<&str> {
4337 let mut trimmed = expr.trim();
4338 while let Some(rest) = trimmed.strip_prefix('&') {
4339 trimmed = rest.trim_start();
4340 }
4341
4342 if trimmed.starts_with('[') {
4343 let close = find_matching_delim(trimmed, 0, b'[', b']')?;
4344 return trimmed.get(1..close);
4345 }
4346
4347 let rest = trimmed.strip_prefix("vec!")?.trim_start();
4348 if !rest.starts_with('[') {
4349 return None;
4350 }
4351 let close = find_matching_delim(rest, 0, b'[', b']')?;
4352 rest.get(1..close)
4353}
4354
4355fn is_expression_string_arg_builder_name(name: &str) -> bool {
4356 matches!(
4357 name,
4358 "json"
4359 | "json_path"
4360 | "json_obj"
4361 | "string_agg"
4362 | "substring"
4363 | "substring_for"
4364 | "inc"
4365 | "is_null_expr"
4366 | "is_not_null_expr"
4367 )
4368}
4369
4370fn is_expression_column_builder_name(name: &str) -> bool {
4371 matches!(
4372 name,
4373 "col"
4374 | "count_distinct"
4375 | "sum"
4376 | "avg"
4377 | "min"
4378 | "max"
4379 | "array_agg"
4380 | "json_agg"
4381 | "jsonb_agg"
4382 | "bool_and"
4383 | "bool_or"
4384 )
4385}
4386
4387fn extract_expr_aliased_names(
4388 expr: &str,
4389 substitutions: Option<&ParamSubstitutions>,
4390 bindings: &LiteralBindings,
4391) -> Vec<String> {
4392 let bytes = expr.as_bytes();
4393 let needle = b"Expr::Aliased";
4394 let mut names = Vec::new();
4395 let mut i = 0usize;
4396
4397 while i < bytes.len() {
4398 if starts_with_bytes(bytes, i, b"//") {
4399 i += 2;
4400 while i < bytes.len() && bytes[i] != b'\n' {
4401 i += 1;
4402 }
4403 continue;
4404 }
4405 if starts_with_bytes(bytes, i, b"/*") {
4406 i = consume_block_comment(bytes, i);
4407 continue;
4408 }
4409 if let Some(next) = consume_rust_literal(bytes, i) {
4410 i = next;
4411 continue;
4412 }
4413
4414 if starts_with_bytes(bytes, i, needle) {
4415 let after = skip_ws(bytes, i + needle.len());
4416 if bytes.get(after).copied() == Some(b'{')
4417 && let Some(close) = find_matching_delim(expr, after, b'{', b'}')
4418 && let Some(body) = expr.get(after + 1..close)
4419 {
4420 names.extend(resolve_struct_string_field(
4421 body,
4422 "name",
4423 substitutions,
4424 bindings,
4425 ));
4426 i = close + 1;
4427 continue;
4428 }
4429 }
4430
4431 i += 1;
4432 }
4433
4434 names
4435}
4436
4437fn resolve_struct_string_field(
4438 body: &str,
4439 field: &str,
4440 substitutions: Option<&ParamSubstitutions>,
4441 bindings: &LiteralBindings,
4442) -> Vec<String> {
4443 let bytes = body.as_bytes();
4444 let mut values = Vec::new();
4445 let mut i = 0usize;
4446
4447 while i < bytes.len() {
4448 if starts_with_bytes(bytes, i, b"//") {
4449 i += 2;
4450 while i < bytes.len() && bytes[i] != b'\n' {
4451 i += 1;
4452 }
4453 continue;
4454 }
4455 if starts_with_bytes(bytes, i, b"/*") {
4456 i = consume_block_comment(bytes, i);
4457 continue;
4458 }
4459 if let Some(next) = consume_rust_literal(bytes, i) {
4460 i = next;
4461 continue;
4462 }
4463
4464 if starts_with_keyword(body, i, field) {
4465 let after_field = skip_ws(bytes, i + field.len());
4466 if bytes.get(after_field).copied() == Some(b':') {
4467 let field_expr = body.get(after_field + 1..).unwrap_or_default();
4468 values.extend(resolve_string_values(
4469 extract_first_argument(field_expr),
4470 substitutions,
4471 bindings,
4472 ));
4473 i = after_field + 1;
4474 continue;
4475 }
4476 }
4477
4478 i += 1;
4479 }
4480
4481 values
4482}
4483
4484#[derive(Debug, Clone, Copy)]
4485struct RustFunctionCall<'a> {
4486 path: &'a str,
4487 name: &'a str,
4488 args: &'a str,
4489 start: usize,
4490}
4491
4492fn scan_rust_function_calls(source: &str) -> Vec<RustFunctionCall<'_>> {
4493 let bytes = source.as_bytes();
4494 let mut calls = Vec::new();
4495 let mut i = 0usize;
4496
4497 while i < bytes.len() {
4498 if starts_with_bytes(bytes, i, b"//") {
4499 i += 2;
4500 while i < bytes.len() && bytes[i] != b'\n' {
4501 i += 1;
4502 }
4503 continue;
4504 }
4505 if starts_with_bytes(bytes, i, b"/*") {
4506 i = consume_block_comment(bytes, i);
4507 continue;
4508 }
4509 if let Some(next) = consume_rust_literal(bytes, i) {
4510 i = next;
4511 continue;
4512 }
4513
4514 if !is_ident_byte(bytes[i])
4515 || i > 0 && (is_ident_byte(bytes[i - 1]) || bytes[i - 1] == b':')
4516 {
4517 i += 1;
4518 continue;
4519 }
4520
4521 let path_start = i;
4522 let mut cursor = i;
4523 let Some((_, ident_end)) = parse_ident_at_bytes(source, cursor) else {
4524 i += 1;
4525 continue;
4526 };
4527 cursor = ident_end;
4528 while starts_with_bytes(bytes, cursor, b"::") {
4529 let next_ident_start = cursor + 2;
4530 let Some((_, next_ident_end)) = parse_ident_at_bytes(source, next_ident_start) else {
4531 break;
4532 };
4533 cursor = next_ident_end;
4534 }
4535
4536 let after_path = skip_ws(bytes, cursor);
4537 if bytes.get(after_path).copied() != Some(b'(') {
4538 i = cursor;
4539 continue;
4540 }
4541 let prev = source.get(..path_start).and_then(|prefix| {
4542 prefix
4543 .bytes()
4544 .rev()
4545 .find(|byte| !byte.is_ascii_whitespace())
4546 });
4547 if prev == Some(b'.') {
4548 i = cursor;
4549 continue;
4550 }
4551
4552 let Some(close) = find_matching_delim(source, after_path, b'(', b')') else {
4553 i = after_path + 1;
4554 continue;
4555 };
4556 let path = source.get(path_start..cursor).unwrap_or_default();
4557 let name = path.rsplit("::").next().unwrap_or(path);
4558 let args = source.get(after_path + 1..close).unwrap_or_default();
4559 calls.push(RustFunctionCall {
4560 path,
4561 name,
4562 args,
4563 start: path_start,
4564 });
4565 i = close + 1;
4566 }
4567
4568 calls
4569}
4570
4571fn resolve_array_string_arg(
4572 args: &str,
4573 index: usize,
4574 substitutions: Option<&ParamSubstitutions>,
4575 bindings: &LiteralBindings,
4576) -> Vec<String> {
4577 split_top_level_args(args)
4578 .get(index)
4579 .map(|arg| resolve_array_string_values(arg, substitutions, bindings))
4580 .unwrap_or_default()
4581}
4582
4583fn resolve_string_arg(
4584 args: &str,
4585 index: usize,
4586 substitutions: Option<&ParamSubstitutions>,
4587 bindings: &LiteralBindings,
4588) -> Vec<String> {
4589 split_top_level_args(args)
4590 .get(index)
4591 .map(|arg| resolve_string_values(arg, substitutions, bindings))
4592 .unwrap_or_default()
4593}
4594
4595fn extract_typed_column_arg(
4596 args: &str,
4597 index: usize,
4598 substitutions: Option<&ParamSubstitutions>,
4599 bindings: &LiteralBindings,
4600) -> Vec<String> {
4601 split_top_level_args(args)
4602 .get(index)
4603 .map(|arg| resolve_typed_column_values(arg, substitutions, bindings))
4604 .unwrap_or_default()
4605}
4606
4607fn extract_typed_column_collection_arg(
4608 args: &str,
4609 index: usize,
4610 substitutions: Option<&ParamSubstitutions>,
4611 bindings: &LiteralBindings,
4612) -> Vec<String> {
4613 let args = split_top_level_args(args);
4614 let Some(arg) = args.get(index) else {
4615 return Vec::new();
4616 };
4617 let Some(inner) = extract_direct_expr_collection_inner(arg) else {
4618 return resolve_typed_column_values(arg, substitutions, bindings);
4619 };
4620
4621 let mut columns = Vec::new();
4622 for expr in split_top_level_args(inner) {
4623 columns.extend(resolve_typed_column_values(expr, substitutions, bindings));
4624 }
4625 dedupe_values(&mut columns);
4626 columns
4627}
4628
4629fn resolve_typed_column_values(
4630 expr: &str,
4631 substitutions: Option<&ParamSubstitutions>,
4632 bindings: &LiteralBindings,
4633) -> Vec<String> {
4634 let mut columns = Vec::new();
4635 let mut visited = HashSet::new();
4636 resolve_typed_column_values_inner(expr, substitutions, bindings, &mut visited, &mut columns);
4637 dedupe_values(&mut columns);
4638 columns
4639}
4640
4641fn resolve_typed_column_values_inner(
4642 expr: &str,
4643 substitutions: Option<&ParamSubstitutions>,
4644 bindings: &LiteralBindings,
4645 visited: &mut HashSet<String>,
4646 columns: &mut Vec<String>,
4647) {
4648 let trimmed = expr.trim();
4649
4650 if let Some(inner) = extract_direct_expr_collection_inner(trimmed) {
4651 for item in split_top_level_args(inner) {
4652 resolve_typed_column_values_inner(item, substitutions, bindings, visited, columns);
4653 }
4654 return;
4655 }
4656
4657 let direct = extract_direct_typed_column_expr(trimmed, substitutions, bindings);
4658 if !direct.is_empty() {
4659 columns.extend(direct);
4660 return;
4661 }
4662
4663 if !is_simple_binding_reference(trimmed) {
4664 return;
4665 }
4666 let Some(key) = binding_lookup_key(trimmed) else {
4667 return;
4668 };
4669 let marker = format!("t:{key}");
4670 if !visited.insert(marker.clone()) {
4671 return;
4672 }
4673
4674 if let Some(substitutions) = substitutions
4675 && let Some(arg_expr) = substitutions.values.get(&key)
4676 {
4677 visited.remove(&marker);
4678 resolve_typed_column_values_inner(
4679 arg_expr,
4680 None,
4681 &substitutions.bindings,
4682 visited,
4683 columns,
4684 );
4685 return;
4686 }
4687
4688 if let Some(exprs) = bindings.typed_scalars.get(&key) {
4689 for expr in exprs {
4690 resolve_typed_column_values_inner(expr, None, bindings, visited, columns);
4691 }
4692 }
4693 if let Some(exprs) = bindings.typed_arrays.get(&key) {
4694 for expr in exprs {
4695 resolve_typed_column_values_inner(expr, None, bindings, visited, columns);
4696 }
4697 }
4698}
4699
4700fn direct_typed_column_expr_has_column(expr: &str) -> bool {
4701 !extract_direct_typed_column_expr(expr, None, &LiteralBindings::default()).is_empty()
4702}
4703
4704fn extract_direct_typed_column_expr(
4705 expr: &str,
4706 substitutions: Option<&ParamSubstitutions>,
4707 bindings: &LiteralBindings,
4708) -> Vec<String> {
4709 let mut columns = Vec::new();
4710 if let Some(column) = extract_typed_column_path_expr(expr) {
4711 columns.push(column);
4712 }
4713 for call in scan_rust_function_calls(expr) {
4714 if call.path.ends_with("TypedColumn::new") {
4715 columns.extend(resolve_string_arg(call.args, 1, substitutions, bindings));
4716 continue;
4717 }
4718 if !call.args.trim().is_empty() || !call.path.contains("::") {
4719 continue;
4720 }
4721 columns.push(call.name.to_string());
4722 }
4723 dedupe_values(&mut columns);
4724 columns
4725}
4726
4727fn extract_typed_column_collection_items(expr: &str) -> Option<Vec<String>> {
4728 let inner = extract_direct_expr_collection_inner(expr)?;
4729 let items = split_top_level_args(inner)
4730 .into_iter()
4731 .map(|item| item.trim().to_string())
4732 .filter(|item| !item.is_empty())
4733 .collect::<Vec<_>>();
4734 if items.is_empty() {
4735 return None;
4736 }
4737
4738 if items
4739 .iter()
4740 .any(|item| direct_typed_column_expr_has_column(item) || is_simple_binding_reference(item))
4741 {
4742 Some(items)
4743 } else {
4744 None
4745 }
4746}
4747
4748fn extract_typed_column_binding_items(
4749 expr: &str,
4750 bindings: &LiteralBindings,
4751) -> Option<Vec<String>> {
4752 if let Some(items) = extract_typed_column_collection_items(expr) {
4753 return Some(items);
4754 }
4755
4756 let items = extract_branch_typed_column_collection_items(expr, bindings);
4757 if items.is_empty() { None } else { Some(items) }
4758}
4759
4760fn extract_branch_typed_column_collection_items(
4761 expr: &str,
4762 bindings: &LiteralBindings,
4763) -> Vec<String> {
4764 let trimmed = expr.trim_start();
4765 let mut out = if trimmed.starts_with("match ") {
4766 extract_match_typed_column_collection_arms(trimmed, bindings)
4767 } else if trimmed.starts_with("if ") {
4768 extract_if_typed_column_collection_blocks(trimmed, bindings)
4769 } else {
4770 Vec::new()
4771 };
4772 dedupe_values(&mut out);
4773 out
4774}
4775
4776fn extract_match_typed_column_collection_arms(
4777 expr: &str,
4778 bindings: &LiteralBindings,
4779) -> Vec<String> {
4780 let Some(open) = find_first_code_byte(expr, b'{') else {
4781 return Vec::new();
4782 };
4783 let Some(close) = find_matching_delim(expr, open, b'{', b'}') else {
4784 return Vec::new();
4785 };
4786 let Some(body) = expr.get(open + 1..close) else {
4787 return Vec::new();
4788 };
4789
4790 let mut out = Vec::new();
4791 for arm in split_top_level_args(body) {
4792 let Some(arrow) = find_top_level_match_arrow(arm) else {
4793 continue;
4794 };
4795 let result = arm.get(arrow + 2..).unwrap_or_default().trim();
4796 out.extend(extract_typed_column_collection_expr_items(result, bindings));
4797 }
4798 out
4799}
4800
4801fn extract_if_typed_column_collection_blocks(
4802 expr: &str,
4803 bindings: &LiteralBindings,
4804) -> Vec<String> {
4805 let mut out = Vec::new();
4806 let mut cursor = 0usize;
4807
4808 while cursor < expr.len() {
4809 let Some(tail) = expr.get(cursor..) else {
4810 break;
4811 };
4812 let Some(open_rel) = find_first_code_byte(tail, b'{') else {
4813 break;
4814 };
4815 let open = cursor + open_rel;
4816 let Some(close) = find_matching_delim(expr, open, b'{', b'}') else {
4817 break;
4818 };
4819 if let Some(block) = expr.get(open + 1..close) {
4820 out.extend(extract_typed_column_collection_expr_items(block, bindings));
4821 }
4822 cursor = close + 1;
4823 }
4824
4825 out
4826}
4827
4828fn extract_typed_column_collection_expr_items(
4829 expr: &str,
4830 bindings: &LiteralBindings,
4831) -> Vec<String> {
4832 let Some(expr) = unwrap_single_block_expr(expr) else {
4833 return Vec::new();
4834 };
4835 if let Some(items) = extract_typed_column_collection_items(expr) {
4836 return items;
4837 }
4838 let Some(key) = binding_lookup_key(expr) else {
4839 return Vec::new();
4840 };
4841 if let Some(items) = bindings.typed_arrays.get(&key) {
4842 return items.clone();
4843 }
4844 bindings
4845 .typed_scalars
4846 .get(&key)
4847 .cloned()
4848 .unwrap_or_default()
4849}
4850
4851fn extract_typed_column_path_expr(expr: &str) -> Option<String> {
4852 let mut trimmed = expr.trim();
4853 while let Some(rest) = trimmed.strip_prefix('&') {
4854 trimmed = rest.trim_start();
4855 }
4856 while trimmed.starts_with('(') && trimmed.ends_with(')') {
4857 let close = find_matching_delim(trimmed, 0, b'(', b')')?;
4858 if close + 1 != trimmed.len() {
4859 break;
4860 }
4861 trimmed = trimmed.get(1..close)?.trim();
4862 }
4863
4864 if trimmed.contains(|ch: char| ch.is_whitespace())
4865 || trimmed.contains(['(', ')', '[', ']', '{', '}', ',', '.'])
4866 {
4867 return None;
4868 }
4869 if !trimmed.contains("::") {
4870 return None;
4871 }
4872
4873 let raw_segment = trimmed.rsplit("::").next()?.trim();
4874 let segment = raw_segment.strip_prefix("r#").unwrap_or(raw_segment);
4875 if segment == "table" {
4876 return None;
4877 }
4878 if segment.is_empty() || !segment.chars().all(|c| c.is_alphanumeric() || c == '_') {
4879 None
4880 } else {
4881 Some(segment.to_string())
4882 }
4883}
4884
4885fn is_simple_binding_reference(expr: &str) -> bool {
4886 let mut trimmed = expr.trim();
4887 while let Some(rest) = trimmed.strip_prefix('&') {
4888 trimmed = rest.trim_start();
4889 }
4890 trimmed = strip_identity_method_suffixes(trimmed);
4891 trimmed = trimmed.trim_matches(|ch: char| matches!(ch, '(' | ')' | '[' | ']'));
4892 while let Some(rest) = trimmed.strip_prefix('&') {
4893 trimmed = rest.trim_start();
4894 }
4895 while trimmed.starts_with('(') && trimmed.ends_with(')') {
4896 let Some(close) = find_matching_delim(trimmed, 0, b'(', b')') else {
4897 return false;
4898 };
4899 if close + 1 != trimmed.len() {
4900 break;
4901 }
4902 trimmed = trimmed.get(1..close).unwrap_or_default().trim();
4903 }
4904
4905 !trimmed.contains("::")
4906 && !trimmed.is_empty()
4907 && trimmed.chars().all(|c| c.is_alphanumeric() || c == '_')
4908}
4909
4910fn extract_related_tables_with_bindings(
4911 line: &str,
4912 substitutions: Option<&ParamSubstitutions>,
4913 bindings: &LiteralBindings,
4914) -> Vec<String> {
4915 let mut tables = Vec::new();
4916 for call in scan_chain_method_calls(line) {
4917 match call.name {
4918 "using_table" | "using_table_as" | "left_join" | "inner_join" | "left_join_as"
4919 | "inner_join_as" | "left_join_conds" | "inner_join_conds" | "join_on"
4920 | "join_on_optional" => {
4921 tables.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
4922 }
4923 "join" | "join_conds" => {
4924 tables.extend(resolve_string_arg(call.args, 1, substitutions, bindings));
4925 }
4926 "update_from" | "delete_using" => {
4927 tables.extend(resolve_array_string_values(
4928 extract_first_argument(call.args),
4929 substitutions,
4930 bindings,
4931 ));
4932 }
4933 _ => {}
4934 }
4935 }
4936 tables = tables
4937 .into_iter()
4938 .filter_map(|table| normalize_related_table_name(&table))
4939 .collect();
4940 dedupe_values(&mut tables);
4941 tables
4942}
4943
4944fn normalize_related_table_name(table: &str) -> Option<String> {
4945 let table = table.trim();
4946 if table.is_empty() {
4947 return None;
4948 }
4949 let base = table.split_whitespace().next().unwrap_or(table);
4950 if base.contains('(') || base.contains(')') {
4951 None
4952 } else {
4953 Some(base.to_string())
4954 }
4955}
4956
4957fn extract_table_aliases_with_bindings(
4958 line: &str,
4959 primary_table: &str,
4960 substitutions: Option<&ParamSubstitutions>,
4961 bindings: &LiteralBindings,
4962 context: &AliasExtractionContext<'_>,
4963) -> HashMap<String, String> {
4964 let mut aliases = HashMap::new();
4965 for call in scan_chain_method_calls(line) {
4966 match call.name {
4967 "table_alias" | "target_alias" => {
4968 for alias in resolve_string_arg(call.args, 0, substitutions, bindings) {
4969 insert_alias(&mut aliases, primary_table, &alias);
4970 }
4971 }
4972 "left_join_as" | "inner_join_as" | "using_table_as" => {
4973 for table in resolve_string_arg(call.args, 0, substitutions, bindings) {
4974 for alias in resolve_string_arg(call.args, 1, substitutions, bindings) {
4975 insert_alias(&mut aliases, &table, &alias);
4976 }
4977 }
4978 }
4979 "using_query_as" => {
4980 let args = split_top_level_args(call.args);
4981 if args.len() < 2 {
4982 continue;
4983 }
4984 let mut tables =
4985 resolve_inline_qail_constructor_tables(args[0], substitutions, bindings);
4986 if tables.is_empty() {
4987 tables.extend(resolve_bound_qail_constructor_tables(
4988 args[0],
4989 context.current_chain,
4990 context.qail_bound_vars,
4991 context.source,
4992 context.local_functions,
4993 substitutions,
4994 bindings,
4995 ));
4996 }
4997 for table in tables {
4998 for alias in resolve_string_arg(call.args, 1, substitutions, bindings) {
4999 insert_alias(&mut aliases, &table, &alias);
5000 }
5001 }
5002 }
5003 "using_table" | "left_join" | "inner_join" | "left_join_conds" | "inner_join_conds" => {
5004 for table in resolve_string_arg(call.args, 0, substitutions, bindings) {
5005 insert_alias_from_table_ref(&mut aliases, &table);
5006 }
5007 }
5008 "join" | "join_conds" => {
5009 for table in resolve_string_arg(call.args, 1, substitutions, bindings) {
5010 insert_alias_from_table_ref(&mut aliases, &table);
5011 }
5012 }
5013 "update_from" | "delete_using" => {
5014 for table in resolve_array_string_values(
5015 extract_first_argument(call.args),
5016 substitutions,
5017 bindings,
5018 ) {
5019 insert_alias_from_table_ref(&mut aliases, &table);
5020 }
5021 }
5022 _ => {}
5023 }
5024 }
5025 aliases
5026}
5027
5028fn resolve_bound_qail_constructor_tables(
5029 expr: &str,
5030 current_chain: &ScannedQailChain,
5031 qail_bound_vars: &[(&str, &ScannedQailChain)],
5032 source: &str,
5033 local_functions: &[LocalFunction],
5034 substitutions: Option<&ParamSubstitutions>,
5035 bindings: &LiteralBindings,
5036) -> Vec<String> {
5037 let Some(key) = binding_lookup_key(expr) else {
5038 return Vec::new();
5039 };
5040 let Some((_, source_chain)) = qail_bound_vars
5041 .iter()
5042 .filter(|(var, source_chain)| {
5043 *var == key
5044 && source_chain.start <= current_chain.start
5045 && current_chain.start
5046 < find_innermost_block_end(source, source_chain.start).unwrap_or(source.len())
5047 && same_enclosing_function(source_chain.start, current_chain.start, local_functions)
5048 })
5049 .max_by_key(|(_, source_chain)| source_chain.start)
5050 else {
5051 return Vec::new();
5052 };
5053
5054 if source_chain.action == "TYPED" {
5055 extract_typed_table_arg(&source_chain.first_arg)
5056 .into_iter()
5057 .collect()
5058 } else {
5059 resolve_string_values(&source_chain.first_arg, substitutions, bindings)
5060 }
5061}
5062
5063fn resolve_inline_qail_constructor_tables(
5064 expr: &str,
5065 substitutions: Option<&ParamSubstitutions>,
5066 bindings: &LiteralBindings,
5067) -> Vec<String> {
5068 let Some(hit) = find_next_qail_constructor(expr, 0) else {
5069 return Vec::new();
5070 };
5071 let args = expr
5072 .get(hit.open_paren + 1..hit.close_paren)
5073 .unwrap_or_default();
5074 let first_arg = extract_first_argument(args);
5075 if hit.action == "TYPED" {
5076 extract_typed_table_arg(first_arg).into_iter().collect()
5077 } else {
5078 resolve_string_values(first_arg, substitutions, bindings)
5079 }
5080}
5081
5082fn insert_alias_from_table_ref(aliases: &mut HashMap<String, String>, table_ref: &str) {
5083 if let Some((table, alias)) = split_table_alias(table_ref) {
5084 insert_alias(aliases, &table, &alias);
5085 }
5086}
5087
5088fn insert_alias(aliases: &mut HashMap<String, String>, table: &str, alias: &str) {
5089 let Some(table) = normalize_related_table_name(table) else {
5090 return;
5091 };
5092 let alias = alias.trim();
5093 if alias.is_empty()
5094 || alias.eq_ignore_ascii_case("as")
5095 || alias == table
5096 || !alias
5097 .chars()
5098 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
5099 {
5100 return;
5101 }
5102 aliases.insert(alias.to_string(), table);
5103}
5104
5105fn split_table_alias(table_ref: &str) -> Option<(String, String)> {
5106 let parts = table_ref.split_whitespace().collect::<Vec<_>>();
5107 match parts.as_slice() {
5108 [table, alias] => Some(((*table).to_string(), (*alias).to_string())),
5109 [table, as_kw, alias] if as_kw.eq_ignore_ascii_case("as") => {
5110 Some(((*table).to_string(), (*alias).to_string()))
5111 }
5112 _ => None,
5113 }
5114}
5115
5116fn extract_scope_checked_entries_with_bindings(
5121 line: &str,
5122 substitutions: Option<&ParamSubstitutions>,
5123 bindings: &LiteralBindings,
5124) -> Vec<(&'static str, String)> {
5125 let mut entries = Vec::new();
5126 for call in scan_chain_method_calls(line) {
5127 let clause = match call.name {
5128 "group_by" => "group_by",
5129 "distinct_on" => "distinct_on",
5130 _ => continue,
5131 };
5132 for entry in
5133 resolve_array_string_values(extract_first_argument(call.args), substitutions, bindings)
5134 {
5135 entries.push((clause, entry));
5136 }
5137 }
5138 entries
5139}
5140
5141fn extract_relation_scope_qualifiers_with_bindings(
5148 line: &str,
5149 primary_table: &str,
5150 substitutions: Option<&ParamSubstitutions>,
5151 bindings: &LiteralBindings,
5152) -> HashSet<String> {
5153 let calls = scan_chain_method_calls(line);
5154 let mut qualifiers = HashSet::new();
5155 let mut primary_aliases = Vec::new();
5156
5157 for call in &calls {
5158 if matches!(call.name, "table_alias" | "target_alias") {
5159 primary_aliases.extend(resolve_string_arg(call.args, 0, substitutions, bindings));
5160 }
5161 }
5162 if primary_aliases.is_empty() {
5163 insert_relation_scope_qualifier(&mut qualifiers, primary_table);
5164 } else {
5165 for alias in primary_aliases {
5166 insert_scope_alias(&mut qualifiers, &alias);
5167 }
5168 }
5169
5170 for call in calls {
5171 match call.name {
5172 "using_table" | "left_join" | "inner_join" | "left_join_conds" | "inner_join_conds"
5173 | "join_on" | "join_on_optional" => {
5174 for table in resolve_string_arg(call.args, 0, substitutions, bindings) {
5175 insert_relation_scope_qualifier(&mut qualifiers, &table);
5176 }
5177 }
5178 "left_join_as" | "inner_join_as" | "using_table_as" => {
5179 for alias in resolve_string_arg(call.args, 1, substitutions, bindings) {
5180 insert_scope_alias(&mut qualifiers, &alias);
5181 }
5182 }
5183 "using_query_as" => {
5184 for alias in resolve_string_arg(call.args, 1, substitutions, bindings) {
5185 insert_scope_alias(&mut qualifiers, &alias);
5186 }
5187 }
5188 "join" | "join_conds" => {
5189 for table in resolve_string_arg(call.args, 1, substitutions, bindings) {
5190 insert_relation_scope_qualifier(&mut qualifiers, &table);
5191 }
5192 }
5193 "update_from" | "delete_using" => {
5194 for table in resolve_array_string_values(
5195 extract_first_argument(call.args),
5196 substitutions,
5197 bindings,
5198 ) {
5199 insert_relation_scope_qualifier(&mut qualifiers, &table);
5200 }
5201 }
5202 _ => {}
5203 }
5204 }
5205
5206 qualifiers
5207}
5208
5209fn insert_scope_alias(qualifiers: &mut HashSet<String>, alias: &str) {
5210 let alias = alias.trim();
5211 if !alias.is_empty()
5212 && alias
5213 .chars()
5214 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
5215 {
5216 qualifiers.insert(alias.to_string());
5217 }
5218}
5219
5220fn insert_relation_scope_qualifier(qualifiers: &mut HashSet<String>, table_ref: &str) {
5221 if let Some((_table, alias)) = split_table_alias(table_ref) {
5222 insert_scope_alias(qualifiers, &alias);
5223 return;
5224 }
5225 let Some(table) = normalize_related_table_name(table_ref) else {
5226 return;
5227 };
5228 qualifiers.insert(table.clone());
5229 if let Some(base) = table.rsplit('.').next()
5230 && base != table
5231 {
5232 qualifiers.insert(base.to_string());
5233 }
5234}
5235
5236fn qualifier_scope_errors(
5243 entries: &[(&'static str, String)],
5244 table: &str,
5245 valid_qualifiers: &HashSet<String>,
5246) -> Vec<String> {
5247 if entries.is_empty() {
5248 return Vec::new();
5249 }
5250
5251 let mut errors = Vec::new();
5252 for (clause, entry) in entries {
5253 let entry = entry.trim();
5254 if entry.contains('(')
5255 || entry.contains('[')
5256 || entry.contains("::")
5257 || entry.contains("->")
5258 || entry.contains(' ')
5259 {
5260 continue;
5261 }
5262 let Some((qualifier, _column)) = entry.rsplit_once('.') else {
5263 continue;
5264 };
5265 if qualifier.is_empty() || qualifier.contains('"') {
5266 continue;
5267 }
5268 if !valid_qualifiers.contains(qualifier) && !matches!(qualifier, "excluded" | "EXCLUDED") {
5269 errors.push(format!(
5270 "{clause} references \"{entry}\" but \"{qualifier}\" is not the FROM table, a joined table, or a declared alias in this query (FROM {table})"
5271 ));
5272 }
5273 }
5274 errors
5275}
5276
5277fn normalize_columns_with_aliases(
5278 columns: &[String],
5279 aliases: &HashMap<String, String>,
5280) -> Vec<String> {
5281 columns
5282 .iter()
5283 .map(|column| normalize_column_with_aliases(column, aliases))
5284 .collect()
5285}
5286
5287fn normalize_column_with_aliases(column: &str, aliases: &HashMap<String, String>) -> String {
5288 let Some((prefix, suffix)) = column.split_once('.') else {
5289 return column.to_string();
5290 };
5291 if let Some(table) = aliases.get(prefix) {
5292 format!("{table}.{suffix}")
5293 } else {
5294 column.to_string()
5295 }
5296}
5297
5298fn chain_has_rls(chain: &str) -> bool {
5299 scan_chain_method_calls(chain)
5300 .into_iter()
5301 .any(|call| matches!(call.name, "with_rls" | "with_rls_policy" | "rls"))
5302}
5303
5304fn chain_has_rls_policy_delegation(chain: &str) -> bool {
5305 scan_chain_method_calls(chain)
5306 .into_iter()
5307 .any(|call| call.name == "with_rls_policy")
5308}
5309
5310fn chain_has_explicit_tenant_scope(
5311 action: &str,
5312 chain: &str,
5313 substitutions: Option<&ParamSubstitutions>,
5314 bindings: &LiteralBindings,
5315) -> bool {
5316 for call in scan_chain_method_calls(chain) {
5317 if call.name == "filter_cond"
5318 && condition_expression_has_tenant_scope(call.args, substitutions, bindings)
5319 {
5320 return true;
5321 }
5322 let is_filter_scope =
5323 string_filter_call_has_tenant_scope(call.name, call.args, substitutions, bindings);
5324 let is_typed_filter_scope =
5325 typed_filter_call_has_tenant_scope(call.name, call.args, substitutions, bindings);
5326 let is_payload_scope = matches!(
5327 call.name,
5328 "set_value" | "set_opt" | "set_coalesce" | "set_coalesce_opt"
5329 ) && matches!(action, "ADD" | "PUT");
5330 if is_typed_filter_scope {
5331 return true;
5332 }
5333 if !(is_filter_scope || is_payload_scope) {
5334 continue;
5335 }
5336 if resolve_string_values(extract_first_argument(call.args), substitutions, bindings)
5337 .into_iter()
5338 .any(|col| is_tenant_identifier(&col))
5339 {
5340 return true;
5341 }
5342 }
5343 false
5344}
5345
5346fn condition_expression_has_tenant_scope(
5347 expr: &str,
5348 substitutions: Option<&ParamSubstitutions>,
5349 bindings: &LiteralBindings,
5350) -> bool {
5351 if condition_struct_has_tenant_scope(expr, substitutions, bindings) {
5352 return true;
5353 }
5354
5355 for call in scan_rust_function_calls(expr) {
5356 if string_filter_call_has_tenant_scope(call.name, call.args, substitutions, bindings) {
5357 return true;
5358 }
5359
5360 if call.name == "cond" {
5361 let args = split_top_level_args(call.args);
5362 let is_scope_left = args
5363 .first()
5364 .map(|left| {
5365 extract_direct_expr_columns(left, substitutions, bindings)
5366 .into_iter()
5367 .any(|col| is_tenant_identifier(&col))
5368 })
5369 .unwrap_or(false);
5370 let is_scope_operator = args
5371 .get(1)
5372 .is_some_and(|op| typed_operator_is_tenant_scope(op));
5373 if is_scope_left && is_scope_operator {
5374 return true;
5375 }
5376 }
5377 }
5378
5379 false
5380}
5381
5382fn condition_struct_has_tenant_scope(
5383 expr: &str,
5384 substitutions: Option<&ParamSubstitutions>,
5385 bindings: &LiteralBindings,
5386) -> bool {
5387 let bytes = expr.as_bytes();
5388 let mut i = 0usize;
5389
5390 while i < bytes.len() {
5391 if starts_with_bytes(bytes, i, b"//") {
5392 i += 2;
5393 while i < bytes.len() && bytes[i] != b'\n' {
5394 i += 1;
5395 }
5396 continue;
5397 }
5398 if starts_with_bytes(bytes, i, b"/*") {
5399 i = consume_block_comment(bytes, i);
5400 continue;
5401 }
5402 if let Some(next) = consume_rust_literal(bytes, i) {
5403 i = next;
5404 continue;
5405 }
5406
5407 if starts_with_keyword(expr, i, "Condition") {
5408 let after = skip_ws(bytes, i + "Condition".len());
5409 if bytes.get(after).copied() == Some(b'{')
5410 && let Some(close) = find_matching_delim(expr, after, b'{', b'}')
5411 && let Some(body) = expr.get(after + 1..close)
5412 {
5413 let has_scope_left =
5414 resolve_struct_direct_expr_column_field(body, "left", substitutions, bindings)
5415 .into_iter()
5416 .any(|col| is_tenant_identifier(&col));
5417 let has_scope_operator = resolve_struct_field_expr(body, "op")
5418 .is_some_and(typed_operator_is_tenant_scope);
5419 if has_scope_left && has_scope_operator {
5420 return true;
5421 }
5422 i = close + 1;
5423 continue;
5424 }
5425 }
5426
5427 i += 1;
5428 }
5429
5430 false
5431}
5432
5433fn resolve_struct_field_expr<'a>(body: &'a str, field: &str) -> Option<&'a str> {
5434 let bytes = body.as_bytes();
5435 let mut i = 0usize;
5436
5437 while i < bytes.len() {
5438 if starts_with_bytes(bytes, i, b"//") {
5439 i += 2;
5440 while i < bytes.len() && bytes[i] != b'\n' {
5441 i += 1;
5442 }
5443 continue;
5444 }
5445 if starts_with_bytes(bytes, i, b"/*") {
5446 i = consume_block_comment(bytes, i);
5447 continue;
5448 }
5449 if let Some(next) = consume_rust_literal(bytes, i) {
5450 i = next;
5451 continue;
5452 }
5453
5454 if starts_with_keyword(body, i, field) {
5455 let after_field = skip_ws(bytes, i + field.len());
5456 if bytes.get(after_field).copied() == Some(b':') {
5457 let field_expr = body.get(after_field + 1..).unwrap_or_default();
5458 return Some(extract_first_argument(field_expr));
5459 }
5460 }
5461
5462 i += 1;
5463 }
5464
5465 None
5466}
5467
5468fn string_filter_call_has_tenant_scope(
5469 name: &str,
5470 args: &str,
5471 substitutions: Option<&ParamSubstitutions>,
5472 bindings: &LiteralBindings,
5473) -> bool {
5474 let is_scope_column =
5475 resolve_string_values(extract_first_argument(args), substitutions, bindings)
5476 .into_iter()
5477 .any(|col| is_tenant_identifier(&col));
5478 if !is_scope_column {
5479 return false;
5480 }
5481
5482 if matches!(name, "eq" | "where_eq" | "is_null") {
5483 return true;
5484 }
5485
5486 name == "filter"
5487 && split_top_level_args(args)
5488 .get(1)
5489 .is_some_and(|op| typed_operator_is_tenant_scope(op))
5490}
5491
5492fn typed_filter_call_has_tenant_scope(
5493 name: &str,
5494 args: &str,
5495 substitutions: Option<&ParamSubstitutions>,
5496 bindings: &LiteralBindings,
5497) -> bool {
5498 let columns = extract_typed_column_arg(args, 0, substitutions, bindings);
5499 if !columns.iter().any(|col| is_tenant_identifier(col)) {
5500 return false;
5501 }
5502
5503 match name {
5504 "typed_eq" => true,
5505 "typed_filter" => split_top_level_args(args)
5506 .get(1)
5507 .is_some_and(|op| typed_operator_is_tenant_scope(op)),
5508 _ => false,
5509 }
5510}
5511
5512fn typed_operator_is_tenant_scope(op: &str) -> bool {
5513 let op = op.trim();
5514 let op = op.rsplit("::").next().unwrap_or(op).trim();
5515 matches!(op, "Eq" | "IsNull")
5516}
5517
5518fn is_tenant_identifier(raw_ident: &str) -> bool {
5519 let without_cast = raw_ident.split("::").next().unwrap_or(raw_ident).trim();
5520 let last_segment = without_cast.rsplit('.').next().unwrap_or(without_cast);
5521 let normalized = last_segment
5522 .trim_matches('"')
5523 .trim_matches('`')
5524 .to_ascii_lowercase();
5525 normalized == "tenant_id"
5526}
5527
5528pub(crate) fn usage_action_to_ast(action: &str) -> Result<crate::ast::Action, String> {
5529 use crate::ast::Action;
5530
5531 match action {
5532 "GET" | "TYPED" => Ok(Action::Get),
5533 "ADD" => Ok(Action::Add),
5534 "SET" => Ok(Action::Set),
5535 "DEL" => Ok(Action::Del),
5536 "PUT" => Ok(Action::Put),
5537 "MERGE" => Ok(Action::Merge),
5538 "EXPORT" => Ok(Action::Export),
5539 "TRUNCATE" => Ok(Action::Truncate),
5540 "EXPLAIN" => Ok(Action::Explain),
5541 "EXPLAIN_ANALYZE" => Ok(Action::ExplainAnalyze),
5542 "LOCK" => Ok(Action::Lock),
5543 _ => Err(format!("unknown scanner action '{}'", action)),
5544 }
5545}
5546
5547pub(crate) fn append_scanned_columns(cmd: &mut crate::ast::Qail, columns: &[String]) {
5548 use crate::ast::Expr;
5549
5550 for col in columns {
5551 if col.contains('(') || col == "*" {
5554 continue;
5555 }
5556 let exists = cmd
5557 .columns
5558 .iter()
5559 .any(|e| matches!(e, Expr::Named(existing) if existing == col));
5560 if !exists {
5561 cmd.columns.push(Expr::Named(col.clone()));
5562 }
5563 }
5564}