Skip to main content

qail_core/build/
scanner.rs

1//! Semantic source scanner for QAIL usage patterns.
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::Path;
6
7/// Extracted QAIL usage from source code
8#[derive(Debug)]
9pub struct QailUsage {
10    /// Source file path.
11    pub file: String,
12    /// Line number (1-indexed).
13    pub line: usize,
14    /// Column number (1-indexed) where `Qail::...` constructor starts.
15    pub column: usize,
16    /// Table name referenced.
17    pub table: String,
18    /// True when table name came from a dynamic expression instead of a
19    /// compile-time string literal.
20    pub is_dynamic_table: bool,
21    /// Column names referenced.
22    pub columns: Vec<String>,
23    /// CRUD action (GET, SET, ADD, DEL, PUT).
24    pub action: String,
25    /// Additional static tables referenced by the chain.
26    ///
27    /// This is used for secondary relation slots such as `MERGE USING table`.
28    pub related_tables: Vec<String>,
29    /// Whether this references a CTE rather than a real table.
30    pub is_cte_ref: bool,
31    /// Whether this query chain includes `.with_rls(` call
32    pub has_rls: bool,
33    /// Whether the chain delegates isolation to DB policies via
34    /// `.with_rls_policy(`. Distinct from `has_rls`: delegation is an
35    /// explicit declaration that no AST predicate is expected, so the
36    /// "scopes nothing" audit must not fire on it.
37    pub rls_policy_delegated: bool,
38    /// Whether this query chain has explicit tenant scope condition
39    /// (e.g. `.eq("tenant_id", ...)` or `.is_null("tenant_id")`).
40    pub has_explicit_tenant_scope: bool,
41    /// Whether the containing file uses `SuperAdminToken::for_system_process()`.
42    /// When true AND the queried table is tenant-scoped, the build emits a
43    /// warning: the query may bypass tenant isolation.
44    pub file_uses_super_admin: bool,
45    /// Qualifier-scope violations found at scan time: GROUP BY / DISTINCT ON
46    /// entries whose `table.` qualifier is neither the FROM table, a joined
47    /// table, a declared alias, nor a visible CTE. These compile and pass
48    /// schema-existence validation but fail at runtime (42P01-class), so the
49    /// build must fail instead.
50    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
123/// Scan Rust source files for QAIL usage patterns
124pub 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
130/// Scan a single Rust source text buffer for QAIL usage patterns.
131///
132/// This is the in-memory counterpart of [`scan_source_files`], used by tools
133/// like LSP servers that work on unsaved editor buffers.
134///
135/// Unlike build-time scanning, this API never emits cargo warnings.
136pub 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
142/// Return true when Rust source contains a real
143/// `SuperAdminToken::for_system_process(...)` call and no
144/// `qail:allow(super_admin)` comment.
145///
146/// Comment and string literal contents are ignored for the function-call check,
147/// and only comments count as allow markers.
148pub 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
615/// Parse `ident = rest` from a let statement (after stripping `let `).
616/// Returns (variable_name, right_hand_side).
617fn parse_simple_let(s: &str) -> Option<(String, &str)> {
618    // Must start with an ident char, not `(` (that's destructuring) or `mut`
619    let s = s.strip_prefix("mut ").unwrap_or(s).trim();
620    if s.starts_with('(') {
621        return None;
622    }
623
624    // Extract identifier
625    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    // Skip optional type annotation  : Type
634    let rest = s[ident.len()..].trim_start();
635    let rest = if rest.starts_with(':') {
636        // Skip past the type, find the `=`
637        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
646/// Extract string literals from static branch expressions.
647/// Handles: `if cond { "a" } else { "b" }` and
648/// `match kind { A => "a", _ => "b" }`.
649fn 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
911/// Parse destructuring let: `let (a, b) = ...;`
912/// Returns vec of (name, possible_values) for each position.
913fn parse_destructuring_let(line: &str) -> Option<Vec<(String, Vec<String>)>> {
914    // Find `let (` or `let mut (`
915    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    // Extract variable names from the tuple pattern
920    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    // Find the RHS after `=`
933    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    // Case 1: Simple tuple  ("a", "b")
938    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    // Case 2: if/else  if cond { ("a", "x") } else { ("b", "y") }
952    if rhs.starts_with("if ") {
953        let mut all_tuples: Vec<Vec<String>> = Vec::new();
954
955        // Extract tuples from each branch
956        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                // Try to extract a tuple from the block
962                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
993/// Extract string literals from a tuple: ("a", "b", "c") → ["a", "b", "c"]
994fn 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    // Find the closing paren (handle nested parens)
999    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
1014/// Find the position of the matching `}` for the first `{`,
1015/// handling nested braces.
1016fn 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
1033/// Count net open delimiters in a line: +1 for `(`, `[`, `{`, -1 for `)`, `]`, `}`.
1034/// Used by the chain scanner to continue joining lines across multi-line arguments.
1035pub(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
2363/// Offsets of `<var>.<method>(` call sites, keyed by receiver variable, for
2364/// every method `accept` recognises. Used for late-bound `.with_rls()` /
2365/// `.with_rls_policy()` applied at the execution site rather than in the
2366/// constructor chain.
2367fn 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
2979/// Parameter indices of local helper functions that apply one of `methods`
2980/// to that parameter inside their body (e.g. `fn run(cmd: Qail, ctx: &Rls)
2981/// { cmd.with_rls_policy(ctx) }` → `run` index 0).
2982fn 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    // ── File-level flags ─────────────────────────────────────────────
3100    // Detect SuperAdminToken::for_system_process() usage anywhere in file.
3101    // Files can opt out with `// qail:allow(super_admin)` comment.
3102    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        // Delegation evidence mirrors the three `has_rls` forms exactly, so a
3173        // late `cmd.with_rls_policy(ctx)` or a helper that applies it is not
3174        // misreported as "scopes NOTHING".
3175        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
3319/// Extract table name from `Qail::typed(...)` patterns.
3320/// Handles generated module markers like `users::table`, and qualified
3321/// marker structs like `schema::Orders`.
3322///
3323/// Examples:
3324///  - `users::table`         → `users`
3325///  - `users::Users`         → `users`
3326///  - `schema::users::table` → `users`  (second-to-last segment)
3327///  - `Orders`               → `orders` (single ident, no ::)
3328pub(crate) fn extract_typed_table_arg(s: &str) -> Option<String> {
3329    let s = s.trim();
3330    // Collect the full path: identifier::Identifier::...
3331    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    // Clean up extracted columns: strip Postgres ::type casts and AS aliases.
3639    // e.g. "id::text" → "id", "conn.id::text as connection_id" → "conn.id",
3640    // "COALESCE(inv.capacity - inv.reserved, 0)::bigint as x" → skipped (expression)
3641    let columns: Vec<String> = columns
3642        .into_iter()
3643        .map(|col| {
3644            // Strip " as alias" suffix (case-insensitive)
3645            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            // Strip ::type cast suffix
3651            if let Some(pos) = col.find("::") {
3652                col[..pos].to_string()
3653            } else {
3654                col
3655            }
3656        })
3657        .filter(|col| {
3658            // Skip expressions that aren't simple column references
3659            !col.contains('(') && !col.contains(')') && !col.contains(' ')
3660        })
3661        .filter(|col| {
3662            // Skip computed alias names — these are not schema columns
3663            !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
5116/// GROUP BY / DISTINCT ON entries from a chain, tagged with the clause name
5117/// they came from. Only these two clauses are scope-checked: their qualifiers
5118/// must reference a relation the query actually joins, and unlike SELECT
5119/// columns they carry no alias/JSON ambiguity worth being lenient about.
5120fn 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
5141/// Relation qualifiers that are actually addressable in the generated SQL.
5142/// PostgreSQL aliases hide the original relation name completely, so this is
5143/// intentionally derived from the builder calls rather than from the broader
5144/// schema-validation table/alias map. Likewise, a CTE name is valid only when
5145/// it is the FROM relation or is explicitly joined by this query; mere lexical
5146/// visibility does not put it in SQL relation scope.
5147fn 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
5236/// Scope-check qualified GROUP BY / DISTINCT ON entries against the tables
5237/// actually present in the query. A qualifier that resolves to a real schema
5238/// table still fails here when that table is not joined — exactly the shape
5239/// that passes schema-existence validation and dies at runtime (42P01-class).
5240/// Expression-ish entries (casts, functions, JSON paths, subscripts, aliases)
5241/// are skipped, mirroring the column validator's leniency.
5242fn 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        // Skip SQL function expressions (e.g., count(*), SUM(amount))
5552        // and wildcard (*) — these are valid SQL, not schema columns
5553        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}