Skip to main content

ddx_core/
rewrite.rs

1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Source-to-source SQL rewriting: find every `grad`/`jvp` marker, replace it
6//! with derivative SQL, leave everything else byte-identical.
7//!
8//! This is Path A (design.md §3.3), the universal path every target relies on.
9//! It is a real subsystem, not a one-liner (design.md §3.2):
10//!
11//! * a **parse-free pre-gate** so a marker-free statement is never parsed, and
12//!   so a `sqlparser` coverage gap can only ever bound a statement that
13//!   *actually contains* a marker (F5);
14//! * **splice by source span**, so everything outside a marker stays
15//!   byte-identical — which requires a UTF-8-aware character-column→byte-offset
16//!   conversion, because `sqlparser` spans are 1-based *characters*, not bytes
17//!   (G3);
18//! * **multiple and nested markers** — spliced in reverse source order, nested
19//!   ones differentiated bottom-up (`grad(grad(f,x),x)` just works);
20//! * a safe **fallback** to whole-statement reprinting on the empty spans the
21//!   API documents as possible.
22//!
23//! Two guards run here, both catching a *silently-wrong* derivative and turning
24//! it into a typed error: the ambiguity guard lives in the engine (F2), and the
25//! CTE-computed-alias guard (F3/G4) lives in [`projection_guard`].
26
27use std::collections::HashSet;
28use std::fmt;
29use std::ops::ControlFlow;
30
31use sqlparser::ast::Spanned;
32use sqlparser::ast::{
33    Expr, Function, ObjectNamePart, Query, Select, SelectItem, SetExpr, Statement, TableFactor,
34    Visit, VisitMut, Visitor, VisitorMut,
35};
36use sqlparser::dialect::Dialect;
37use sqlparser::parser::Parser;
38use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer};
39
40use crate::colref::{ColRef, IdentCasing};
41use crate::engine::{differentiate, jvp, positional_args, RuleRegistry};
42use crate::error::{DiffError, Result};
43
44/// Which marker a function call is.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum MarkerKind {
47    Grad,
48    Jvp,
49}
50
51/// Classify a function call as a marker — but only an **unqualified**,
52/// case-folded `grad`/`jvp` (design.md §3.2, F8). `myschema.grad(...)` and a
53/// user's own multi-part function are left alone.
54pub(crate) fn marker_kind(f: &Function) -> Option<MarkerKind> {
55    if f.name.0.len() != 1 {
56        return None;
57    }
58    let ObjectNamePart::Identifier(id) = &f.name.0[0] else {
59        return None;
60    };
61    match id.value.to_ascii_lowercase().as_str() {
62        "grad" => Some(MarkerKind::Grad),
63        "jvp" => Some(MarkerKind::Jvp),
64        _ => None,
65    }
66}
67
68impl MarkerKind {
69    /// The marker's SQL function name, for display.
70    fn name(self) -> &'static str {
71        match self {
72            MarkerKind::Grad => "grad",
73            MarkerKind::Jvp => "jvp",
74        }
75    }
76}
77
78fn is_marker_expr(e: &Expr) -> bool {
79    matches!(e, Expr::Function(f) if marker_kind(f).is_some())
80}
81
82/// The [`MarkerKind`] of an expression that is a marker call, else `None`.
83fn marker_expr_kind(e: &Expr) -> Option<MarkerKind> {
84    match e {
85        Expr::Function(f) => marker_kind(f),
86        _ => None,
87    }
88}
89
90/// A human-inspectable account of what [`crate::Ddx::rewrite_sql`] would do to a
91/// statement, produced by [`crate::Ddx::explain`] — so a user can see the
92/// derivative SQL *before* running anything. Inspect the fields directly, or
93/// print the whole thing (`Display`) for a readable summary.
94#[derive(Debug, Clone)]
95pub struct Explanation {
96    /// The original statement, unchanged.
97    pub original: String,
98    /// The statement after every `grad`/`jvp` marker is rewritten to derivative
99    /// SQL — exactly what [`crate::Ddx::rewrite_sql`] returns.
100    pub rewritten: String,
101    /// One entry per top-level marker, in source order. Empty when the statement
102    /// has no marker, or in the rare empty-span reprint fallback (where the
103    /// rewrite still appears in [`Explanation::rewritten`]).
104    pub steps: Vec<ExplainStep>,
105}
106
107/// One marker and the derivative SQL it rewrites to (part of an [`Explanation`]).
108#[derive(Debug, Clone)]
109pub struct ExplainStep {
110    /// The marker function: `"grad"` or `"jvp"`.
111    pub function: &'static str,
112    /// The original marker call, exactly as written (e.g. `grad(sin(x), x)`).
113    pub marker: String,
114    /// The derivative SQL it becomes (e.g. `(cos(x))`).
115    pub derivative: String,
116}
117
118impl fmt::Display for Explanation {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        if self.steps.is_empty() {
121            return write!(
122                f,
123                "No grad/jvp markers to rewrite; the statement is unchanged:\n  {}",
124                self.original
125            );
126        }
127        let n = self.steps.len();
128        writeln!(
129            f,
130            "ddx rewrites {n} marker{}:",
131            if n == 1 { "" } else { "s" }
132        )?;
133        for step in &self.steps {
134            writeln!(f, "  • {} → {}", step.marker, step.derivative)?;
135        }
136        writeln!(f)?;
137        writeln!(f, "  from: {}", self.original)?;
138        write!(f, "  into: {}", self.rewritten)
139    }
140}
141
142/// The parse-free pre-gate: a case-insensitive scan for an *unqualified*
143/// `grad(`/`jvp(` — the equivalent of `(?i)(?:^|[^A-Za-z0-9_.])(grad|jvp)\s*\(`,
144/// hand-rolled so the core depends on `sqlparser` only (design.md §3.2/§6). A
145/// statement that doesn't hit is returned verbatim, never parsed (F5). It is a
146/// best-effort filter: a false positive (e.g. `grad(` inside a string literal)
147/// only costs a parse that then finds no marker, never a wrong rewrite.
148fn pre_gate_hit(sql: &str) -> bool {
149    // ASCII-lowercasing preserves byte length and offsets, so indices found in
150    // `lower` are valid char boundaries in `sql`.
151    let lower = sql.to_ascii_lowercase();
152    for kw in ["grad", "jvp"] {
153        let mut from = 0;
154        while let Some(rel) = lower[from..].find(kw) {
155            let idx = from + rel;
156            from = idx + 1;
157
158            // Preceding character must not be part of a longer identifier or a
159            // qualifier (`.`), so `mygrad(` and `schema.grad(` don't match.
160            let ok_prev = idx == 0
161                || sql[..idx].chars().next_back().is_some_and(|prev| {
162                    !(prev.is_ascii_alphanumeric() || prev == '_' || prev == '.')
163                });
164            if !ok_prev {
165                continue;
166            }
167
168            // The next significant character must be `(`. sqlparser treats a SQL
169            // comment as lexical whitespace, so `grad /* c */ (x, x)` and
170            // `grad-- c\n(x, x)` are genuine marker calls — the scan skips
171            // comments as well as whitespace, or the gate would miss them and
172            // let a real marker reach execution un-rewritten (#52).
173            let after = &sql[idx + kw.len()..];
174            if after[skip_trivia(after)..].starts_with('(') {
175                return true;
176            }
177        }
178    }
179    false
180}
181
182/// Byte offset of the first significant character in `s`, skipping leading
183/// whitespace and SQL comments (`-- … end-of-line`, and `/* … */` block
184/// comments, which nest in Postgres/DuckDB) — the trivia sqlparser's tokenizer
185/// discards. Returns `s.len()` if the rest is all trivia.
186///
187/// Delimiters (`-`, `/`, `*`, whitespace, `\n`) are all ASCII, and a UTF-8
188/// continuation byte is never equal to an ASCII byte, so scanning by bytes is
189/// safe even with multibyte text inside a comment.
190fn skip_trivia(s: &str) -> usize {
191    let b = s.as_bytes();
192    let n = b.len();
193    let mut i = 0;
194    loop {
195        while i < n && b[i].is_ascii_whitespace() {
196            i += 1;
197        }
198        // Line comment: `--` to end of line (or input).
199        if i + 1 < n && b[i] == b'-' && b[i + 1] == b'-' {
200            i += 2;
201            while i < n && b[i] != b'\n' {
202                i += 1;
203            }
204            continue;
205        }
206        // Block comment: `/* … */`, nesting-aware.
207        if i + 1 < n && b[i] == b'/' && b[i + 1] == b'*' {
208            i += 2;
209            let mut depth = 1usize;
210            while i < n && depth > 0 {
211                if i + 1 < n && b[i] == b'/' && b[i + 1] == b'*' {
212                    depth += 1;
213                    i += 2;
214                } else if i + 1 < n && b[i] == b'*' && b[i + 1] == b'/' {
215                    depth -= 1;
216                    i += 2;
217                } else {
218                    i += 1;
219                }
220            }
221            continue;
222        }
223        break;
224    }
225    i
226}
227
228/// The public entry point behind [`crate::Ddx::rewrite_sql`].
229pub(crate) fn rewrite_sql(
230    sql: &str,
231    dialect: &dyn Dialect,
232    casing: IdentCasing,
233    reg: &RuleRegistry,
234) -> Result<String> {
235    match resolve_markers(sql, dialect, casing, reg)? {
236        Resolution::Verbatim => Ok(sql.to_string()),
237        Resolution::Reprinted(out) => Ok(out),
238        Resolution::Spliced(repls) => Ok(apply_splice(sql, repls)),
239    }
240}
241
242/// The public entry point behind [`crate::Ddx::explain`]: the same marker
243/// resolution as [`rewrite_sql`], but returned as inspectable structure (each
244/// marker and the derivative SQL it becomes) plus the final rewritten
245/// statement — so a user can see what will happen before running anything.
246pub(crate) fn explain_sql(
247    sql: &str,
248    dialect: &dyn Dialect,
249    casing: IdentCasing,
250    reg: &RuleRegistry,
251) -> Result<Explanation> {
252    let (rewritten, steps) = match resolve_markers(sql, dialect, casing, reg)? {
253        Resolution::Verbatim => (sql.to_string(), Vec::new()),
254        // The empty-span fallback reprints the whole statement, so per-marker
255        // byte ranges aren't available — report the rewrite without steps.
256        Resolution::Reprinted(out) => (out, Vec::new()),
257        Resolution::Spliced(repls) => {
258            let steps = repls
259                .iter()
260                .map(|r| ExplainStep {
261                    function: r.function.name(),
262                    marker: r.marker.clone(),
263                    derivative: r.derivative.clone(),
264                })
265                .collect();
266            (apply_splice(sql, repls), steps)
267        }
268    };
269    Ok(Explanation {
270        original: sql.to_string(),
271        rewritten,
272        steps,
273    })
274}
275
276/// Splice each replacement's derivative into `sql` by byte range, in reverse
277/// source order so earlier offsets stay valid.
278fn apply_splice(sql: &str, mut repls: Vec<Replacement>) -> String {
279    repls.sort_by_key(|r| std::cmp::Reverse(r.start));
280    let mut out = sql.to_string();
281    for r in repls {
282        out.replace_range(r.start..r.end, &r.derivative);
283    }
284    out
285}
286
287/// One marker's resolution: the byte range it occupies, its original call text,
288/// and the derivative SQL it becomes.
289struct Replacement {
290    start: usize,
291    end: usize,
292    function: MarkerKind,
293    marker: String,
294    derivative: String,
295}
296
297/// How a statement resolves against its markers.
298enum Resolution {
299    /// No real marker — the input is returned unchanged.
300    Verbatim,
301    /// The empty-span fallback: only the fully-rewritten text is available (no
302    /// per-marker byte ranges).
303    Reprinted(String),
304    /// The normal path: one [`Replacement`] per outermost marker.
305    Spliced(Vec<Replacement>),
306}
307
308/// Run the marker pipeline (pre-gate → parse → collect → per-marker derivative)
309/// *without* splicing, so both [`rewrite_sql`] and [`explain_sql`] share it.
310fn resolve_markers(
311    sql: &str,
312    dialect: &dyn Dialect,
313    casing: IdentCasing,
314    reg: &RuleRegistry,
315) -> Result<Resolution> {
316    // 1. Parse-free pre-gate: no marker syntax, no parse, byte-identical out.
317    if !pre_gate_hit(sql) {
318        return Ok(Resolution::Verbatim);
319    }
320
321    // 2. The statement (or one of them) looks like it carries a marker; parse.
322    let statements = Parser::parse_sql(dialect, sql)
323        .map_err(|e| DiffError::Parse(format!("failed to parse SQL: {e}")))?;
324
325    // 3. Statement-level context for the projection-boundary guard (F3/G4):
326    //    the names of every *computed* select-list alias of a CTE/derived table.
327    let mut aliases = ComputedAliases::default();
328    for stmt in &statements {
329        collect_computed_aliases(stmt, &mut aliases);
330    }
331
332    // 4. Locate the outermost markers (with their source spans). Nested markers
333    //    are handled when their enclosing outermost marker is differentiated.
334    let mut collector = MarkerCollector::default();
335    for stmt in &statements {
336        let _ = Visit::visit(stmt, &mut collector);
337    }
338    // Pre-gate false positive (e.g. only qualified markers, or `grad(` inside a
339    // string literal): nothing to rewrite.
340    if collector.found.is_empty() {
341        return Ok(Resolution::Verbatim);
342    }
343
344    // 5. Empty spans are documented as possible; fall back to a correct (if not
345    //    byte-identical) whole-statement reprint if any marker lacks a span.
346    if collector.found.iter().any(|(span, _)| is_empty_span(span)) {
347        return Ok(Resolution::Reprinted(reprint_fallback(
348            statements, casing, reg, &aliases,
349        )?));
350    }
351
352    // 6. Compute each replacement's byte range and derivative.
353    //
354    //    The marker name position (`span.start`) is reliable, but the Function's
355    //    `span.end` is NOT: sqlparser under-reports it when the call's last
356    //    argument ends in a `Cast` (excludes ` AS <type>`) or `Nested` (excludes
357    //    the closing `)`), so trusting it under-splices and leaves corrupt SQL
358    //    behind (#57). Instead, find the call's matching close paren over the
359    //    token stream — re-tokenizing with the same dialect, which lexes strings
360    //    and comments as single tokens so their parens don't miscount.
361    let tokens = Tokenizer::new(dialect, sql)
362        .tokenize_with_location()
363        .map_err(|e| DiffError::Parse(format!("failed to tokenize SQL: {e}")))?;
364
365    let mut repls = Vec::with_capacity(collector.found.len());
366    for (span, marker_expr) in &collector.found {
367        let derivative = differentiate_marker_tree(marker_expr, casing, reg, &aliases)?;
368        let function = marker_expr_kind(marker_expr)
369            .ok_or_else(|| DiffError::Internal("outermost marker lost its kind".into()))?;
370        let start = locate(sql, span.start, false)
371            .ok_or_else(|| DiffError::Internal("marker span start out of range".into()))?;
372        let close = marker_call_close(&tokens, span.start).ok_or_else(|| {
373            DiffError::Internal("could not locate the marker call's closing parenthesis".into())
374        })?;
375        // `close` is the location of the `)`; the exclusive byte end is one
376        // character past it.
377        let end = locate(sql, close, true)
378            .ok_or_else(|| DiffError::Internal("marker span end out of range".into()))?;
379        let marker = sql[start..end].to_string();
380        repls.push(Replacement {
381            start,
382            end,
383            function,
384            marker,
385            derivative,
386        });
387    }
388    Ok(Resolution::Spliced(repls))
389}
390
391/// Differentiate one (possibly nested) marker subtree, returning the derivative
392/// rendered to SQL text, parenthesized so it keeps the call's precedence.
393fn differentiate_marker_tree(
394    marker_expr: &Expr,
395    casing: IdentCasing,
396    reg: &RuleRegistry,
397    aliases: &ComputedAliases,
398) -> Result<String> {
399    let mut clone = marker_expr.clone();
400    let mut rw = MarkerRewriter {
401        casing,
402        reg,
403        aliases,
404    };
405    if let ControlFlow::Break(err) = VisitMut::visit(&mut clone, &mut rw) {
406        return Err(err);
407    }
408    Ok(clone.to_string())
409}
410
411/// The whole-statement reprint fallback (empty-span case).
412fn reprint_fallback(
413    mut statements: Vec<Statement>,
414    casing: IdentCasing,
415    reg: &RuleRegistry,
416    aliases: &ComputedAliases,
417) -> Result<String> {
418    for stmt in &mut statements {
419        let mut rw = MarkerRewriter {
420            casing,
421            reg,
422            aliases,
423        };
424        if let ControlFlow::Break(err) = VisitMut::visit(stmt, &mut rw) {
425            return Err(err);
426        }
427    }
428    Ok(statements
429        .iter()
430        .map(ToString::to_string)
431        .collect::<Vec<_>>()
432        .join("; "))
433}
434
435// ---------------------------------------------------------------------------
436// Differentiating a single marker (args assumed already marker-free)
437// ---------------------------------------------------------------------------
438
439/// Differentiate a single marker call whose arguments are already free of
440/// nested markers (guaranteed by the bottom-up post-order walk).
441fn differentiate_marker(f: &Function, casing: IdentCasing, reg: &RuleRegistry) -> Result<Expr> {
442    let kind = marker_kind(f).ok_or_else(|| DiffError::Internal("not a marker".into()))?;
443    let args = positional_args(f).ok_or_else(|| {
444        DiffError::InvalidMarker("marker call has non-positional arguments".into())
445    })?;
446    match kind {
447        MarkerKind::Grad => {
448            if args.len() != 2 {
449                return Err(DiffError::InvalidMarker(format!(
450                    "grad(expr, column) expects 2 arguments, got {}",
451                    args.len()
452                )));
453            }
454            let wrt = ColRef::from_wrt_arg("grad", args[1])?;
455            differentiate(args[0], &wrt, casing, reg)
456        }
457        MarkerKind::Jvp => {
458            if args.len() != 3 {
459                return Err(DiffError::InvalidMarker(format!(
460                    "jvp(expr, column, tangent) expects 3 arguments, got {}",
461                    args.len()
462                )));
463            }
464            let wrt = ColRef::from_wrt_arg("jvp", args[1])?;
465            let seeds = vec![(wrt, args[2].clone())];
466            jvp(args[0], &seeds, casing, reg)
467        }
468    }
469}
470
471/// The projection-boundary guard (design.md §3.5, F3/G4).
472///
473/// Errors if a marker argument references an identifier that is a *computed*
474/// select-list alias of a CTE/derived table in the same statement, used as a
475/// *non-`wrt`* term — differentiating it would silently treat an upstream
476/// expression as a constant and drop gradient terms. The carve-out (G4): when
477/// the alias *is* the `wrt` itself, every occurrence is the differentiation
478/// leaf, so no term can be dropped and the guard stays quiet.
479fn projection_guard(f: &Function, aliases: &ComputedAliases) -> Result<()> {
480    if aliases.is_empty() {
481        return Ok(());
482    }
483    let Some(args) = positional_args(f) else {
484        return Ok(());
485    };
486    let Some(expr_arg) = args.first() else {
487        return Ok(());
488    };
489    let wrt_name = args
490        .get(1)
491        .and_then(|a| ColRef::from_expr(a))
492        .map(|c| c.name.value.to_ascii_lowercase());
493
494    let mut cols = ColumnCollector::default();
495    let _ = Visit::visit(*expr_arg, &mut cols);
496    for c in cols.cols {
497        let lname = c.name.value.to_ascii_lowercase();
498        // Carve-out (G4): the wrt itself is always a leaf; never an error.
499        if Some(&lname) == wrt_name.as_ref() {
500            continue;
501        }
502        let is_boundary = match &c.qualifier {
503            // A bare occurrence could bind to any computed alias in scope.
504            None => aliases.bare.contains(&lname),
505            // A qualified occurrence crosses a projection boundary only if the
506            // qualifier names the relation that actually owns the alias. A base
507            // column qualified to an unrelated table (e.g. `w.s` when the alias
508            // `s` belongs to a different CTE) is NOT the alias — preserving the
509            // qualifier-awareness the F2 ambiguity guard is built on.
510            Some(q) => aliases
511                .qualified
512                .contains(&(q.value.to_ascii_lowercase(), lname.clone())),
513        };
514        if is_boundary {
515            return Err(DiffError::ProjectionBoundary(format!(
516                "`{}` is a computed select-list alias of a CTE/derived table used \
517                 as a non-differentiation term; grad does not see through the \
518                 projection boundary — differentiate inside that CTE instead",
519                c.display()
520            )));
521        }
522    }
523    Ok(())
524}
525
526// ---------------------------------------------------------------------------
527// Visitors
528// ---------------------------------------------------------------------------
529
530/// Collects the outermost marker expressions (with their spans), skipping
531/// markers nested inside another marker's arguments (handled bottom-up later).
532#[derive(Default)]
533struct MarkerCollector {
534    depth: usize,
535    found: Vec<(Span, Expr)>,
536}
537
538impl Visitor for MarkerCollector {
539    type Break = ();
540
541    fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
542        if is_marker_expr(expr) {
543            if self.depth == 0 {
544                self.found.push((expr.span(), expr.clone()));
545            }
546            self.depth += 1;
547        }
548        ControlFlow::Continue(())
549    }
550
551    fn post_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
552        if is_marker_expr(expr) {
553            self.depth -= 1;
554        }
555        ControlFlow::Continue(())
556    }
557}
558
559/// Replaces each marker with `Nested(derivative)`, bottom-up (post-order), so a
560/// nested marker's own arguments are already marker-free when it is reached.
561struct MarkerRewriter<'a> {
562    casing: IdentCasing,
563    reg: &'a RuleRegistry,
564    aliases: &'a ComputedAliases,
565}
566
567impl VisitorMut for MarkerRewriter<'_> {
568    type Break = DiffError;
569
570    fn post_visit_expr(&mut self, expr: &mut Expr) -> ControlFlow<DiffError> {
571        let replacement = match expr {
572            Expr::Function(f) if marker_kind(f).is_some() => {
573                if let Err(err) = projection_guard(f, self.aliases) {
574                    return ControlFlow::Break(err);
575                }
576                match differentiate_marker(f, self.casing, self.reg) {
577                    Ok(d) => Some(d),
578                    Err(err) => return ControlFlow::Break(err),
579                }
580            }
581            _ => None,
582        };
583        if let Some(d) = replacement {
584            *expr = Expr::Nested(Box::new(d));
585        }
586        ControlFlow::Continue(())
587    }
588}
589
590/// Collects the column references directly appearing in an expression tree.
591#[derive(Default)]
592struct ColumnCollector {
593    cols: Vec<ColRef>,
594}
595
596impl Visitor for ColumnCollector {
597    type Break = ();
598
599    fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
600        match expr {
601            Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
602                if let Some(cr) = ColRef::from_expr(expr) {
603                    self.cols.push(cr);
604                }
605            }
606            _ => {}
607        }
608        ControlFlow::Continue(())
609    }
610}
611
612// ---------------------------------------------------------------------------
613// Computed-alias collection for the projection-boundary guard
614// ---------------------------------------------------------------------------
615
616/// The *computed* select-list aliases of the CTEs/derived tables in a
617/// statement, recorded so the guard can distinguish a reference that crosses a
618/// projection boundary from a same-named base column that does not.
619#[derive(Default)]
620struct ComputedAliases {
621    /// Alias names referenceable by a *bare* (unqualified) occurrence.
622    bare: HashSet<String>,
623    /// `(owning relation name, alias name)` for CTE/derived-table computed
624    /// aliases — so a *qualified* occurrence `rel.alias` is matched only against
625    /// the relation that actually owns it. This is what keeps a base column
626    /// like `w.s` from colliding with an unrelated CTE alias `s` (F2's
627    /// qualifier-awareness, applied to the F3/G4 guard).
628    qualified: HashSet<(String, String)>,
629}
630
631impl ComputedAliases {
632    fn is_empty(&self) -> bool {
633        self.bare.is_empty() && self.qualified.is_empty()
634    }
635}
636
637fn collect_computed_aliases(stmt: &Statement, out: &mut ComputedAliases) {
638    match stmt {
639        Statement::Query(q) => walk_query(q, None, out),
640        Statement::Insert(insert) => {
641            if let Some(source) = &insert.source {
642                walk_query(source, None, out);
643            }
644        }
645        _ => {}
646    }
647}
648
649/// `owner` is the name of the relation whose *own* projection aliases we are
650/// collecting (a CTE name, or a derived-table alias) — `None` for the outer
651/// query's own select list, whose aliases can only be referenced bare.
652fn walk_query(q: &Query, owner: Option<&str>, out: &mut ComputedAliases) {
653    if let Some(with) = &q.with {
654        for cte in &with.cte_tables {
655            let name = cte.alias.name.value.to_ascii_lowercase();
656            walk_query(&cte.query, Some(&name), out);
657        }
658    }
659    walk_set_expr(&q.body, owner, out);
660}
661
662fn walk_set_expr(body: &SetExpr, owner: Option<&str>, out: &mut ComputedAliases) {
663    match body {
664        SetExpr::Select(select) => walk_select(select, owner, out),
665        SetExpr::Query(q) => walk_query(q, owner, out),
666        SetExpr::SetOperation { left, right, .. } => {
667            walk_set_expr(left, owner, out);
668            walk_set_expr(right, owner, out);
669        }
670        _ => {}
671    }
672}
673
674fn walk_select(select: &Select, owner: Option<&str>, out: &mut ComputedAliases) {
675    for item in &select.projection {
676        if let SelectItem::ExprWithAlias { expr, alias } = item {
677            // A *computed* alias is one whose projected expression is not a
678            // plain column reference. `ColRef::from_expr` is the single place
679            // that recognizes a column reference (seeing through `Nested`).
680            if ColRef::from_expr(expr).is_none() {
681                let name = alias.value.to_ascii_lowercase();
682                if let Some(o) = owner {
683                    out.qualified.insert((o.to_string(), name.clone()));
684                }
685                out.bare.insert(name);
686            }
687        }
688    }
689    // Recurse into derived tables (FROM subqueries), which are projection
690    // boundaries too — a derived table's aliases are owned by its own alias.
691    for twj in &select.from {
692        walk_table_factor(&twj.relation, out);
693        for join in &twj.joins {
694            walk_table_factor(&join.relation, out);
695        }
696    }
697}
698
699fn walk_table_factor(tf: &TableFactor, out: &mut ComputedAliases) {
700    if let TableFactor::Derived {
701        subquery, alias, ..
702    } = tf
703    {
704        let owner = alias.as_ref().map(|a| a.name.value.to_ascii_lowercase());
705        walk_query(subquery, owner.as_deref(), out);
706    }
707}
708
709/// The [`Location`] of the `)` that closes the marker call whose name token
710/// starts at `name_start`, found by matching parentheses over the token stream.
711///
712/// This is authoritative where `Expr::span()` is not: sqlparser under-reports a
713/// `Function`'s span end when its last argument ends in a `Cast` (the ` AS
714/// <type>` tail is excluded) or a `Nested` (the closing `)` is excluded), so a
715/// span-based splice leaves trailing bytes behind and corrupts the output
716/// (#57). Matching over tokens is exact because the tokenizer lexes string
717/// literals and comments into single tokens, so parentheses inside them are
718/// never counted. The marker name position (`name_start`) is itself reliable;
719/// between it and the call's `(` there is only trivia (skipped here).
720fn marker_call_close(tokens: &[TokenWithSpan], name_start: Location) -> Option<Location> {
721    let mut depth = 0usize;
722    let mut opened = false;
723    for t in tokens.iter().filter(|t| t.span.start >= name_start) {
724        match t.token {
725            Token::LParen => {
726                depth += 1;
727                opened = true;
728            }
729            Token::RParen => {
730                depth = depth.checked_sub(1)?;
731                if opened && depth == 0 {
732                    return Some(t.span.start);
733                }
734            }
735            _ => {}
736        }
737    }
738    None
739}
740
741// ---------------------------------------------------------------------------
742// Span (1-based character line/column) → byte offset conversion (G3)
743// ---------------------------------------------------------------------------
744
745/// `sqlparser` uses `line: 0`/`column: 0` for an empty/unknown location.
746fn is_empty_span(span: &Span) -> bool {
747    span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
748}
749
750/// Convert a 1-based (line, character-column) [`Location`] to a byte offset in
751/// `sql`. Character-column, not byte-column: a multibyte character before the
752/// target shifts the byte offset past the column number (G3).
753///
754/// With `past = false` the returned offset is the *start* byte of the character
755/// at `loc`; with `past = true` it is the byte *one past* that character — used
756/// for an inclusive span end, so the whole marker (its closing `)` included) is
757/// covered by `start..end`.
758fn locate(sql: &str, loc: Location, past: bool) -> Option<usize> {
759    let mut line: u64 = 1;
760    let mut col: u64 = 1;
761    for (byte_idx, ch) in sql.char_indices() {
762        if line == loc.line && col == loc.column {
763            return Some(if past {
764                byte_idx + ch.len_utf8()
765            } else {
766                byte_idx
767            });
768        }
769        if ch == '\n' {
770            line += 1;
771            col = 1;
772        } else {
773            col += 1;
774        }
775    }
776    // A location just past the final character maps to the end of the string.
777    if line == loc.line && col == loc.column {
778        return Some(sql.len());
779    }
780    None
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn pre_gate_matches_unqualified_markers() {
789        assert!(pre_gate_hit("SELECT grad(x, x) FROM t"));
790        assert!(pre_gate_hit("SELECT jvp(x, x, dx) FROM t"));
791        assert!(pre_gate_hit("grad(x,x)")); // at start of input
792        assert!(pre_gate_hit("SELECT GRAD (x, x) FROM t")); // case + whitespace
793        assert!(pre_gate_hit("SELECT AVG(grad(x, x)) FROM t")); // after `(`
794    }
795
796    #[test]
797    fn pre_gate_rejects_non_markers() {
798        assert!(!pre_gate_hit("SELECT a + b FROM t")); // no marker
799        assert!(!pre_gate_hit("SELECT mygrad(x) FROM t")); // longer identifier
800        assert!(!pre_gate_hit("SELECT schema.grad(x, x) FROM t")); // qualified
801        assert!(!pre_gate_hit("SELECT grad AS g FROM t")); // no open paren
802        assert!(!pre_gate_hit("SELECT upgrade(x) FROM t")); // 'grad' inside a word
803    }
804}