squonk-ast 2.0.0

Dialect-agnostic SQL abstract syntax tree for the squonk toolkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Moderately AI Inc.

//! The multi-claimant statement-head ledger — one enumerable record of every leading
//! keyword two or more features grammar-claim, and how the permissive `LENIENT` union
//! resolves each.
//!
//! # Why this exists
//!
//! The three self-consistency registries in the sibling `conflict` module catch the
//! contentions that have *no* defined resolution (`LexicalConflict` for a shared tokenizer
//! trigger, `FeatureDependencyViolation` for a base-flag gap, `GrammarConflict` for an
//! undefined parser-position shadow). Their contract is the mirror image of this table's:
//! a multi-claimant head that a preset *does* union with a documented, deterministic
//! resolution — a lookahead split, a fixed dispatch precedence, or a deliberate one-reading
//! exclusion — is conflict-*free* and gets **no** registry variant. Those resolutions used
//! to live only in scattered per-arm code comments across the parser and in the `LENIENT`
//! preset's field docs, so no reader could enumerate them or test them against each other.
//! This ledger is that missing artifact: the single enumerable source of every contested
//! statement head and the reading `LENIENT` picks.
//!
//! It is dialect **data** — a `const` slice of `&'static str` pointers, not runtime
//! machinery. Enforcement (the union-property invariant, a parse-entry assert, an xtask
//! lint) is other tickets' work; this module ships the data and one consistency test that
//! keeps the `LENIENT` exclusion columns honest against `FeatureSet::LENIENT`.
//!
//! # The exception source for the Lenient union property
//!
//! `oracle-parity-lenient` specifies Lenient's union property — *any statement
//! accepted by any enabled preset must be accepted by Lenient, except where a deliberate
//! exclusion is documented*. The [`OneReadingExclusion`](HeadResolution::OneReadingExclusion)
//! and [`Route`](HeadResolution::Route) entries here are the sanctioned statement-head
//! exclusions that property consumes: a new head-level exclusion requires a row here, not
//! an allowlist line. (Lexical-trigger sacrifices — the `"` / `[` / `$` conflicts — are the
//! separate concern of `LexicalConflict` and the `LENIENT` module's rules 1-8.)

use super::FeatureSet;

/// How the permissive union resolves a statement head that two or more features claim.
///
/// MECE over the multi-claimant heads: a head is a clean lookahead split
/// ([`MeceLookahead`](Self::MeceLookahead)), a precedence-resolved overlap
/// ([`DispatchOrderUnion`](Self::DispatchOrderUnion)), a forgone rival reading
/// ([`OneReadingExclusion`](Self::OneReadingExclusion)), or a forgone wholesale alternative
/// grammar ([`Route`](Self::Route)). The first two keep *both* claimants enabled under
/// `LENIENT`; the last two turn one claimant *off* — the two exclusion kinds, whose forgone
/// flags are the union property's sanctioned head-level exceptions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HeadResolution {
    /// The claimants share a leading keyword but split cleanly on a lookahead token (a
    /// second keyword or a follow token), so no input is ambiguous and the union keeps
    /// **both** readings. `LOAD DATA` vs `LOAD <extension>` (split on the second word) is
    /// the archetype.
    MeceLookahead,
    /// The claimants overlap on some input with no lookahead to separate them, but a fixed
    /// dispatch order / precedence picks one reading for the overlap while still admitting
    /// the other's distinct tail — so the union keeps **both** grammars, one of which wins
    /// the shared prefix. `VACUUM <name>` (the DuckDB qualified-name tail takes precedence,
    /// the SQLite `INTO` tail still admitted after) is the archetype.
    DispatchOrderUnion,
    /// The claimants genuinely collide on some input with no lookahead or precedence able
    /// to keep both readable, so the union turns one claimant **off** and forgoes that
    /// reading. `DO` (MySQL `DO <expr-list>` collides with the PostgreSQL code block on
    /// `DO 'x'`) is the archetype.
    OneReadingExclusion,
    /// One claimant is a wholesale alternative grammar — a *route* that structurally
    /// replaces rather than extends the other — so the union forgoes the route to keep the
    /// richer / more permissive grammar. The MySQL account-based `GRANT` route displacing
    /// the extended standard/PostgreSQL grant grammar is the archetype. (A route is an
    /// exclusion by whole-grammar rather than by a single overlapping input; both this and
    /// [`OneReadingExclusion`](Self::OneReadingExclusion) turn a claimant off under `LENIENT`.)
    Route,
}

/// One contested statement head: the leading keyword(s), the features that claim it, how
/// the union resolves them, the reading `LENIENT` picks, and where the resolution is
/// implemented.
///
/// A row is dialect data with `&'static str` columns; the parser-code `doc` pointer is
/// prose (the parser lives in the `squonk` crate, which this crate cannot intra-doc
/// link into), while the flag names in `claimants` / `lenient_excludes` match the
/// `FeatureSet` fields verbatim so the consistency test can cross-check them.
#[derive(Clone, Copy, Debug)]
pub struct MultiClaimantHead {
    /// The leading keyword(s) that dispatch this head (e.g. `["DO"]`, `["ALTER", "VIEW"]`).
    pub heads: &'static [&'static str],
    /// The `FeatureSet` flag names whose grammars claim this head, spelled exactly as the
    /// struct fields (e.g. `"do_statement"`, `"do_expression_list"`).
    pub claimants: &'static [&'static str],
    /// How the union resolves the contention.
    pub resolution: HeadResolution,
    /// The subset of `claimants` that `FeatureSet::LENIENT` turns **off** — empty for the
    /// union kinds ([`MeceLookahead`](HeadResolution::MeceLookahead) /
    /// [`DispatchOrderUnion`](HeadResolution::DispatchOrderUnion), which keep every
    /// claimant), non-empty for the exclusion kinds
    /// ([`OneReadingExclusion`](HeadResolution::OneReadingExclusion) /
    /// [`Route`](HeadResolution::Route)). This is the union property's exception column.
    pub lenient_excludes: &'static [&'static str],
    /// The reading `LENIENT` accepts for this head, in prose.
    pub lenient_reading: &'static str,
    /// Where the resolution is implemented — a prose pointer to the parser function(s) that
    /// carry the per-arm comment the ledger summarizes.
    pub doc: &'static str,
}

/// Every statement head two or more features grammar-claim, with the permissive union's
/// resolution for each — the enumerable replacement for the scattered per-arm comments.
///
/// Verified entry-by-entry against the cited parser code. The exclusion
/// rows ([`OneReadingExclusion`](HeadResolution::OneReadingExclusion) /
/// [`Route`](HeadResolution::Route)) are exactly the head-level flags `FeatureSet::LENIENT`
/// turns off; the consistency test (`lenient_exclusions_match_the_ledger`) proves the
/// correspondence in both directions.
pub const MULTI_CLAIMANT_STATEMENT_HEADS: &[MultiClaimantHead] = &[
    MultiClaimantHead {
        heads: &["DO"],
        claimants: &["do_statement", "do_expression_list"],
        resolution: HeadResolution::OneReadingExclusion,
        lenient_excludes: &["do_expression_list"],
        lenient_reading: "PostgreSQL's `DO [LANGUAGE <lang>] $$…$$` anonymous code block; \
            MySQL's `DO <expr-list>` collides on inputs like `DO 'x'` and cannot express the \
            `LANGUAGE` clause, so it is forgone.",
        doc: "squonk/src/parser/query.rs `DO` dispatch arms; \
            util.rs `parse_do_statement` / `parse_do_expressions_statement`.",
    },
    MultiClaimantHead {
        heads: &["PREPARE", "EXECUTE", "DEALLOCATE"],
        claimants: &["prepared_statements", "prepared_statements_from"],
        resolution: HeadResolution::OneReadingExclusion,
        lenient_excludes: &["prepared_statements_from"],
        lenient_reading: "DuckDB's typed-`AS` lifecycle (`PREPARE p AS <stmt>`, \
            `EXECUTE name(<args>)`, `DEALLOCATE`); MySQL's `PREPARE … FROM` / \
            `EXECUTE … USING @var` is a different grammar on the same three keywords with no \
            positional-argument spelling, so it is forgone.",
        doc: "squonk/src/parser/query.rs `PREPARE`/`EXECUTE`/`DEALLOCATE` dispatch arms; \
            util.rs `parse_prepare_statement` / `parse_prepare_from_statement` / \
            `parse_deallocate_statement`.",
    },
    MultiClaimantHead {
        heads: &["COPY"],
        claimants: &["copy", "copy_into"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both kept — PostgreSQL's `COPY <table> {FROM|TO}` and Snowflake's `COPY INTO` split on the `INTO` lookahead: `COPY` routes to `copy` while `COPY INTO` routes to `copy_into`.",
        doc: "squonk/src/parser/query.rs `COPY` dispatch and util.rs `parse_copy_or_copy_into_statement`.",
    },
    MultiClaimantHead {
        heads: &["DESCRIBE", "DESC", "SUMMARIZE"],
        claimants: &["describe", "describe_summarize"],
        resolution: HeadResolution::DispatchOrderUnion,
        lenient_excludes: &[],
        lenient_reading: "both kept — `DESCRIBE`/`DESC` and `SUMMARIZE` remain exposed with Lenient's documented dispatch split by parser arm and shape.",
        doc: "squonk/src/parser/query.rs `describe`/`describe_summarize` dispatch and from.rs `parse_describe_summarize_statement`.",
    },
    MultiClaimantHead {
        heads: &["GRANT", "REVOKE"],
        claimants: &[
            "access_control_account_grants",
            "access_control_extended_objects",
        ],
        resolution: HeadResolution::Route,
        lenient_excludes: &["access_control_account_grants"],
        lenient_reading: "the extended standard/PostgreSQL object-and-role grammar (schema \
            objects, `GRANTED BY`, `CASCADE`, routine signatures, the `{GRANT|ADMIN} OPTION \
            FOR` REVOKE prefix); MySQL's account-based grant route structurally replaces (does \
            not extend) it, so the route is forgone.",
        doc: "squonk/src/parser/dcl.rs `parse_grant_kind` / `parse_revoke_kind` \
            (the `access_control_account_grants` route branches to \
            `parse_account_grant` / `parse_account_revoke`).",
    },
    MultiClaimantHead {
        heads: &["LOCK", "UNLOCK"],
        claimants: &["lock_tables", "lock_instance"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both MySQL grammars — `LOCK/UNLOCK {TABLES|TABLE}` and `LOCK \
            INSTANCE FOR BACKUP` / `UNLOCK INSTANCE` — kept, split on the second word. The \
            (unimplemented) PostgreSQL statement-level mode-list reading of `LOCK` will take \
            its own future gate, owing the same one-reading decision `DO` got; that gate does \
            not exist yet, so nothing is resolved away today.",
        doc: "squonk/src/parser/query.rs `LOCK`/`UNLOCK` dispatch arms \
            (`peek_nth_starts_table_or_tables` vs `INSTANCE` second-word split).",
    },
    MultiClaimantHead {
        heads: &["LOAD"],
        claimants: &["load_data", "load_extension", "key_cache_statements"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "all three readings kept, split on the follow token: MySQL `LOAD \
            {DATA|XML}` (second word), MySQL `LOAD INDEX INTO CACHE` (the `key_cache_statements` \
            `LOAD INDEX` two-token lookahead), and PostgreSQL/DuckDB `LOAD <extension>` (the \
            fall-through bare `LOAD`).",
        doc: "squonk/src/parser/query.rs `LOAD DATA` / `LOAD INDEX` / `LOAD` dispatch arms \
            (ordered so the two-word forms are checked before the bare `load_extension` arm); \
            util.rs `parse_load_statement`.",
    },
    MultiClaimantHead {
        heads: &["VACUUM"],
        claimants: &["vacuum", "vacuum_analyze"],
        resolution: HeadResolution::DispatchOrderUnion,
        lenient_excludes: &[],
        lenient_reading: "both tails kept; on the overlapping `VACUUM <name>` operand the \
            DuckDB `vacuum_analyze` reading takes precedence (a *qualified* table name via \
            `parse_object_name`) over the SQLite `vacuum` bare-schema `parse_ident`, and the \
            SQLite `INTO <expr>` clause is still admitted afterwards. This is a one-reading \
            precedence on the shared operand, NOT a pure addition.",
        doc: "squonk/src/parser/util.rs `parse_vacuum_statement` (the `duck`-branch \
            precedence on the name operand; see the in-function comment).",
    },
    MultiClaimantHead {
        heads: &["ANALYZE"],
        claimants: &["analyze", "table_maintenance"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both kept — MySQL `ANALYZE {TABLE|TABLES} …` (the \
            `table_maintenance` verb family, which always requires the `TABLE`/`TABLES` \
            lookahead) and the SQLite/DuckDB bare `ANALYZE [<table> [(<cols>)]]`; a bare \
            `ANALYZE` falls through to the SQLite/DuckDB reading.",
        doc: "squonk/src/parser/query.rs `table_maintenance` arm placed before the bare \
            `ANALYZE` arm (`peek_starts_table_maintenance` insists on `TABLE`/`TABLES`).",
    },
    MultiClaimantHead {
        heads: &["ALTER", "VIEW"],
        claimants: &["view_definition_options", "alter_object_set_schema"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both kept — MySQL view redefinition (`ALGORITHM`/`SQL SECURITY` \
            prefix, or a `(cols)` / `AS <query>` tail) vs DuckDB's `SET SCHEMA` relocation, \
            split by lookahead on the bare `ALTER VIEW <name>` head (an `IF EXISTS` guard or a \
            `SET SCHEMA` tail routes to the relocation; a `(`/`AS` tail to the redefinition).",
        doc: "squonk/src/parser/ddl.rs `parse_alter` `view_definition_options` block \
            (the `relocates` lookahead split when `alter_object_set_schema` is also on).",
    },
    MultiClaimantHead {
        heads: &["ALTER", "DATABASE", "SCHEMA"],
        claimants: &["alter_database", "alter_database_options"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both kept — DuckDB's `SET ALIAS TO` relocation vs MySQL's option \
            list, split by lookahead on the shared `ALTER {DATABASE|SCHEMA}` head (an `IF \
            EXISTS` guard or a `SET` tail after the name is the relocation; an option keyword \
            leading with no name, or a name followed by a non-`SET` tail, is the option list).",
        doc: "squonk/src/parser/ddl.rs `parse_alter_database_head` (the both-on \
            disambiguator).",
    },
    MultiClaimantHead {
        heads: &["DROP"],
        claimants: &["drop_database", "index_drop_on_table"],
        resolution: HeadResolution::OneReadingExclusion,
        lenient_excludes: &["drop_database", "index_drop_on_table"],
        lenient_reading: "the shared name-list drop grammar kept for both sub-heads: `DROP \
            {DATABASE|SCHEMA}` stays the PostgreSQL/DuckDB name-list-plus-`CASCADE` form \
            (MySQL's single-name `drop_database` synonym would displace it), and `DROP INDEX \
            <name>[, …]` stays the bare-name form (MySQL's mandatory-`ON` `index_drop_on_table` \
            would displace it). Both MySQL displacements are forgone.",
        doc: "squonk/src/parser/ddl.rs `parse_drop` dispatch (the `drop_database` and \
            `index_drop_on_table` interceptors before `parse_drop_object_kind`).",
    },
    MultiClaimantHead {
        heads: &["IMPORT"],
        claimants: &["import_table", "export_import_database"],
        resolution: HeadResolution::MeceLookahead,
        lenient_excludes: &[],
        lenient_reading: "both kept — MySQL `IMPORT TABLE FROM …` vs DuckDB `IMPORT DATABASE`, \
            split on the second keyword (`TABLE` vs `DATABASE`), the `import_table` arm checked \
            before the `export_import_database` arm.",
        doc: "squonk/src/parser/query.rs `IMPORT TABLE` / `IMPORT` dispatch arms.",
    },
];

/// Predicate that reads a single gate from a resolved [`FeatureSet`].
pub type FeatureGatePredicate = fn(&FeatureSet) -> bool;

/// Named optional parent/base gate for a one-claimant statement head.
pub type NamedFeatureGate = (&'static str, FeatureGatePredicate);

/// One-claimant statement-head entries that are not true contender pairs.
///
/// These are not included in
/// [`MULTI_CLAIMANT_STATEMENT_HEADS`], whose invariant is "two or more claimants,"
/// but they still need explicit registration so feature truth tables remain complete.
#[derive(Clone, Copy, Debug)]
#[allow(dead_code)]
pub struct BaseVsFeatureStatementHead {
    /// The leading keyword(s) that dispatch this head.
    pub heads: &'static [&'static str],
    /// The single `FeatureSet` flag that contributes this head.
    pub feature: &'static str,
    /// Reads the flag from a resolved preset.
    pub is_on: FeatureGatePredicate,
    /// Optional parent/base gate name and predicate if the feature refines a broader parser
    /// gate; this is documentation-only metadata for this ledger.
    pub base_gate: Option<NamedFeatureGate>,
    /// Where the parser resolution is implemented.
    pub doc: &'static str,
}

/// One-claimant statement heads that refine a broader base gate (not multi-claimant pairs).
///
/// Complements [`MULTI_CLAIMANT_STATEMENT_HEADS`]: each row documents a single feature
/// that owns a head without contending with another feature for the same lead keyword.
#[allow(dead_code)]
pub const BASE_VS_FEATURE_STATEMENT_HEADS: &[BaseVsFeatureStatementHead] = &[
    BaseVsFeatureStatementHead {
        heads: &["SET"],
        feature: "variable_assignment",
        is_on: |f| f.session_variables.variable_assignment,
        base_gate: Some(("show_syntax.session_statements", |f| {
            f.show_syntax.session_statements
        })),
        doc: "squonk/src/parser/query.rs `session` dispatch to `parse_set`, and dcl.rs parse_set's `parse_mysql_set_variables` branch.",
    },
    BaseVsFeatureStatementHead {
        heads: &["UPDATE"],
        feature: "update_extensions",
        is_on: |f| f.utility_syntax.update_extensions,
        base_gate: None,
        doc: "squonk/src/parser/query.rs `UPDATE` dispatch branch with `peek_starts_update_extensions` before `parse_update_statement_with`.",
    },
    BaseVsFeatureStatementHead {
        heads: &["CACHE", "LOAD INDEX"],
        feature: "key_cache_statements",
        is_on: |f| f.utility_syntax.key_cache_statements,
        base_gate: None,
        doc: "squonk/src/parser/query.rs `CACHE` / `LOAD INDEX` dispatch arms, plus util.rs `parse_cache_index_statement` and `parse_load_index_statement`.",
    },
];

#[cfg(test)]
mod tests {
    use super::super::FeatureSet;
    use super::*;

    /// One statement-head claimant gate, valued across the five core presets.
    ///
    /// `get` reads the *live* preset (all struct spreads resolved), so an inherited value is
    /// measured through the spread, not mirrored from a base const — the whole point of the
    /// value pin below.
    struct HeadGateValueRow {
        /// A `FeatureSet` sub-flag that appears as a `claimants` entry in the ledger.
        flag: &'static str,
        /// Reads the flag from a resolved preset.
        get: fn(&FeatureSet) -> bool,
        /// The pinned value in each preset, index-aligned with `CORE_PRESETS`.
        expected: [bool; 5],
    }

    const T: bool = true;
    const F: bool = false;

    /// The five presets the value pin ranges over, in the column order the `expected`
    /// arrays use.
    const CORE_PRESETS: [(&str, FeatureSet); 5] = [
        ("ANSI", FeatureSet::ANSI),
        ("POSTGRES", FeatureSet::POSTGRES),
        ("SQLITE", FeatureSet::SQLITE),
        ("MYSQL", FeatureSet::MYSQL),
        ("DUCKDB", FeatureSet::DUCKDB),
    ];

    /// Every ledger claimant flag, valued by preset — `[ANSI, POSTGRES, SQLITE, MYSQL,
    /// DUCKDB]`. The pinned cells are the measured `FeatureSet` truth, re-derived by running
    /// this test; `value_pinned_gates_cover_every_ledger_claimant` proves this table's flag
    /// set is exactly the ledger's claimants, so a new multi-claimant head forces a new row.
    const STATEMENT_HEAD_GATE_VALUES: &[HeadGateValueRow] = &[
        // DO — PostgreSQL's anonymous code block vs MySQL's `DO <expr-list>`.
        HeadGateValueRow {
            flag: "do_statement",
            get: |f| f.utility_syntax.do_statement,
            expected: [F, T, F, F, F],
        },
        HeadGateValueRow {
            flag: "do_expression_list",
            get: |f| f.utility_syntax.do_expression_list,
            expected: [F, F, F, T, F],
        },
        // PREPARE/EXECUTE/DEALLOCATE — DuckDB/PostgreSQL typed-`AS` lifecycle vs MySQL `FROM`.
        HeadGateValueRow {
            flag: "prepared_statements",
            get: |f| f.utility_syntax.prepared_statements,
            expected: [F, T, F, F, T],
        },
        HeadGateValueRow {
            flag: "prepared_statements_from",
            get: |f| f.utility_syntax.prepared_statements_from,
            expected: [F, F, F, T, F],
        },
        // GRANT/REVOKE — the extended standard grammar (on by default, off in MySQL/SQLite)
        // vs MySQL's account-based route.
        HeadGateValueRow {
            flag: "access_control_account_grants",
            get: |f| f.access_control_syntax.access_control_account_grants,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "access_control_extended_objects",
            get: |f| f.access_control_syntax.access_control_extended_objects,
            expected: [T, T, F, F, T],
        },
        // COPY — PostgreSQL-style copy vs Snowflake `COPY INTO` split by lookahead.
        HeadGateValueRow {
            flag: "copy",
            get: |f| f.utility_syntax.copy,
            expected: [F, T, F, F, T],
        },
        HeadGateValueRow {
            flag: "copy_into",
            get: |f| f.utility_syntax.copy_into,
            expected: [F, F, F, F, F],
        },
        // DESCRIBE/SUMMARIZE — parser-route split by statement family.
        HeadGateValueRow {
            flag: "describe",
            get: |f| f.show_syntax.describe,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "describe_summarize",
            get: |f| f.show_syntax.describe_summarize,
            expected: [F, F, F, F, T],
        },
        // SET — the generic session grammar vs MySQL's `variable_assignment` comma-list route.
        HeadGateValueRow {
            flag: "variable_assignment",
            get: |f| f.session_variables.variable_assignment,
            expected: [F, F, F, T, F],
        },
        // LOCK/UNLOCK — MySQL-only.
        HeadGateValueRow {
            flag: "lock_tables",
            get: |f| f.utility_syntax.lock_tables,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "lock_instance",
            get: |f| f.utility_syntax.lock_instance,
            expected: [F, F, F, T, F],
        },
        // LOAD — MySQL `LOAD DATA` / key-cache vs PostgreSQL/DuckDB `LOAD <extension>`.
        HeadGateValueRow {
            flag: "load_data",
            get: |f| f.utility_syntax.load_data,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "load_extension",
            get: |f| f.utility_syntax.load_extension,
            expected: [F, T, F, F, T],
        },
        HeadGateValueRow {
            flag: "key_cache_statements",
            get: |f| f.utility_syntax.key_cache_statements,
            expected: [F, F, F, T, F],
        },
        // VACUUM — SQLite `VACUUM … INTO` vs DuckDB `VACUUM [ANALYZE]`.
        HeadGateValueRow {
            flag: "vacuum",
            get: |f| f.maintenance_syntax.vacuum,
            expected: [F, F, T, F, F],
        },
        HeadGateValueRow {
            flag: "vacuum_analyze",
            get: |f| f.maintenance_syntax.vacuum_analyze,
            expected: [F, F, F, F, T],
        },
        // ANALYZE — SQLite/DuckDB bare `ANALYZE` vs MySQL's `ANALYZE TABLE` verb family.
        HeadGateValueRow {
            flag: "analyze",
            get: |f| f.maintenance_syntax.analyze,
            expected: [F, F, T, F, T],
        },
        HeadGateValueRow {
            flag: "table_maintenance",
            get: |f| f.maintenance_syntax.table_maintenance,
            expected: [F, F, F, T, F],
        },
        // UPDATE — DuckDB's `UPDATE EXTENSIONS`.
        HeadGateValueRow {
            flag: "update_extensions",
            get: |f| f.utility_syntax.update_extensions,
            expected: [F, F, F, F, T],
        },
        // ALTER VIEW — MySQL redefinition vs DuckDB `SET SCHEMA` relocation.
        HeadGateValueRow {
            flag: "view_definition_options",
            get: |f| f.view_sequence_clause_syntax.view_definition_options,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "alter_object_set_schema",
            get: |f| f.statement_ddl_gates.alter_object_set_schema,
            expected: [F, F, F, F, T],
        },
        // ALTER DATABASE/SCHEMA — DuckDB `SET ALIAS TO` relocation vs MySQL option list.
        HeadGateValueRow {
            flag: "alter_database",
            get: |f| f.statement_ddl_gates.alter_database,
            expected: [F, F, F, F, T],
        },
        HeadGateValueRow {
            flag: "alter_database_options",
            get: |f| f.statement_ddl_gates.alter_database_options,
            expected: [F, F, F, T, F],
        },
        // DROP — MySQL's `drop_database` synonym / mandatory-`ON` index drop displacements.
        HeadGateValueRow {
            flag: "drop_database",
            get: |f| f.statement_ddl_gates.drop_database,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "index_drop_on_table",
            get: |f| f.index_alter_syntax.index_drop_on_table,
            expected: [F, F, F, T, F],
        },
        // IMPORT — MySQL `IMPORT TABLE` vs DuckDB `IMPORT DATABASE`.
        HeadGateValueRow {
            flag: "import_table",
            get: |f| f.utility_syntax.import_table,
            expected: [F, F, F, T, F],
        },
        HeadGateValueRow {
            flag: "export_import_database",
            get: |f| f.utility_syntax.export_import_database,
            expected: [F, F, F, F, T],
        },
    ];

    /// Pin every ledger claimant gate's *value* in each core preset.
    ///
    /// The full-struct divergence tests in `duckdb.rs` assert `duck.utility_syntax ==
    /// UtilitySyntax { …deltas…, ..pg.utility_syntax }` — they pin the divergence *set*, so a
    /// base-preset flip (say `POSTGRES.utility_syntax.load_data` armed by mistake) flows into
    /// both the actual DuckDB preset and the expected struct through the same `..pg` spread
    /// and the test still passes. This test reads each flag by absolute value across all five
    /// presets, so any base flip that a `..UtilitySyntax::POSTGRES` / `..UtilitySyntax::ANSI`
    /// (etc.) spread silently inherits changes a pinned cell and fails here. The sharpest
    /// guarded case is a MySQL-only gate riding an all-false ANSI spread (`load_data`,
    /// `lock_tables`, …): were ANSI to arm it, MySQL would inherit `true` with no local edit.
    #[test]
    fn statement_head_gate_values_are_pinned_per_preset() {
        for row in STATEMENT_HEAD_GATE_VALUES {
            for ((name, preset), &expected) in CORE_PRESETS.iter().zip(row.expected.iter()) {
                assert_eq!(
                    (row.get)(preset),
                    expected,
                    "{}: expected {expected} in {name} — a statement-head gate value drifted \
                     (a base-preset flip may have silently propagated through a struct spread)",
                    row.flag,
                );
            }
        }
    }

    /// The value pin covers exactly the ledger's claimants: a new multi-claimant or
    /// base-vs-feature entry forces a new valued row (with its own measured per-preset cells),
    /// so the value-level coverage cannot silently fall behind the ledger. Kept separate from
    /// the value assertions so the flag *set* is gated even if a value cell is later,
    /// deliberately, changed.
    #[test]
    fn value_pinned_gates_cover_every_ledger_claimant() {
        let mut from_ledger: Vec<&str> = MULTI_CLAIMANT_STATEMENT_HEADS
            .iter()
            .flat_map(|head| head.claimants.iter().copied())
            .chain(
                BASE_VS_FEATURE_STATEMENT_HEADS
                    .iter()
                    .map(|head| head.feature),
            )
            .collect();
        from_ledger.sort_unstable();
        from_ledger.dedup();

        let mut valued: Vec<&str> = STATEMENT_HEAD_GATE_VALUES
            .iter()
            .map(|row| row.flag)
            .collect();
        valued.sort_unstable();
        valued.dedup();

        assert_eq!(
            from_ledger, valued,
            "the value pin must value exactly the ledger's claimant flags — add or remove a row in STATEMENT_HEAD_GATE_VALUES to match a ledger or base-vs-feature change",
        );
    }

    /// Every ledger row is internally coherent: a resolution kind that keeps both
    /// claimants excludes nothing, and an exclusion kind excludes at least one *listed*
    /// claimant. This runs in the default (ANSI-only) build, so the ledger's shape is
    /// gated even without the `lenient` feature.
    #[test]
    fn ledger_rows_are_internally_coherent() {
        for head in MULTI_CLAIMANT_STATEMENT_HEADS {
            match head.resolution {
                HeadResolution::MeceLookahead | HeadResolution::DispatchOrderUnion => {
                    assert!(
                        head.lenient_excludes.is_empty(),
                        "{:?}: union kinds keep every claimant, so lenient_excludes must be empty",
                        head.heads,
                    );
                }
                HeadResolution::OneReadingExclusion | HeadResolution::Route => {
                    assert!(
                        !head.lenient_excludes.is_empty(),
                        "{:?}: exclusion kinds must forgo at least one claimant",
                        head.heads,
                    );
                }
            }
            for excluded in head.lenient_excludes {
                assert!(
                    head.claimants.contains(excluded),
                    "{:?}: excluded flag {excluded:?} is not among the row's claimants",
                    head.heads,
                );
            }
        }
    }

    #[test]
    fn multi_claimant_rows_are_purely_multi_claimant() {
        assert!(
            MULTI_CLAIMANT_STATEMENT_HEADS
                .iter()
                .all(|head| head.claimants.len() >= 2),
            "single-claimant entries belong in BASE_VS_FEATURE_STATEMENT_HEADS, not the multi table",
        );
    }

    #[test]
    fn single_claimant_heads_are_registered_once() {
        let mut from_base: Vec<&str> = BASE_VS_FEATURE_STATEMENT_HEADS
            .iter()
            .map(|row| row.feature)
            .collect();
        from_base.sort_unstable();
        from_base.dedup();

        let mut from_multi: Vec<&str> = MULTI_CLAIMANT_STATEMENT_HEADS
            .iter()
            .flat_map(|head| head.claimants.iter().copied())
            .collect();
        from_multi.sort_unstable();
        from_multi.dedup();

        let mut base_off_lenient: Vec<&str> = BASE_VS_FEATURE_STATEMENT_HEADS
            .iter()
            .filter(|row| !(row.is_on)(&FeatureSet::LENIENT))
            .map(|row| row.feature)
            .collect();
        base_off_lenient.sort_unstable();

        assert_eq!(
            from_base,
            [
                "key_cache_statements",
                "update_extensions",
                "variable_assignment"
            ],
            "unexpected base-vs-feature registration set",
        );
        assert!(
            !from_multi.contains(&"variable_assignment"),
            "`variable_assignment` should stay out of the multi table",
        );
        assert!(
            !from_multi.contains(&"update_extensions"),
            "`update_extensions` should stay out of the multi table",
        );
        assert!(
            from_multi.contains(&"key_cache_statements"),
            "`key_cache_statements` should remain in the multi table due `LOAD` sharing",
        );
        assert_eq!(
            base_off_lenient,
            ["variable_assignment"],
            "base-vs-feature lenient-off feature set changed; update LENIENT expectations if intentional",
        );
    }

    /// The exclusion columns match `FeatureSet::LENIENT` in both directions: every
    /// statement-head flag Lenient turns off in multi-contender rows appears as an excluded
    /// claimant in exactly one row, and every excluded claimant in those rows is genuinely off
    /// in Lenient. This is the artifact `oracle-parity-lenient` consumes — a new
    /// head-level exclusion needs a ledger row, not an allowlist line.
    ///
    /// Gated on the `lenient` feature because `FeatureSet::LENIENT` compiles only there;
    /// the scope is the statement-head exclusions, NOT every `false` flag in the preset
    /// (lexical sacrifices — `double_quoted_strings`, `subscript`, `money_literals`, … — are
    /// `LexicalConflict`'s concern, and restriction flags default off as pure permissiveness).
    #[cfg(feature = "lenient")]
    #[test]
    fn lenient_exclusions_match_the_ledger() {
        let lenient = FeatureSet::LENIENT;
        // The statement-head claimants Lenient documents as "stays off" (lenient.rs), paired
        // with their live flag value so the assertion reads the real preset, not a mirror.
        let stays_off: [(&str, bool); 5] = [
            (
                "do_expression_list",
                lenient.utility_syntax.do_expression_list,
            ),
            (
                "prepared_statements_from",
                lenient.utility_syntax.prepared_statements_from,
            ),
            ("drop_database", lenient.statement_ddl_gates.drop_database),
            (
                "index_drop_on_table",
                lenient.index_alter_syntax.index_drop_on_table,
            ),
            (
                "access_control_account_grants",
                lenient.access_control_syntax.access_control_account_grants,
            ),
        ];
        for (name, is_on) in stays_off {
            assert!(
                !is_on,
                "Lenient documents {name:?} as off, but the preset has it on"
            );
        }

        let mut from_ledger: Vec<&str> = MULTI_CLAIMANT_STATEMENT_HEADS
            .iter()
            .flat_map(|head| head.lenient_excludes.iter().copied())
            .collect();
        from_ledger.sort_unstable();
        // No head-level exclusion is recorded twice.
        let deduped = {
            let mut d = from_ledger.clone();
            d.dedup();
            d
        };
        assert_eq!(
            from_ledger, deduped,
            "a flag is excluded by two ledger rows"
        );

        let mut expected: Vec<&str> = stays_off.iter().map(|(name, _)| *name).collect();
        expected.sort_unstable();
        assert_eq!(
            from_ledger, expected,
            "the ledger's exclusion columns must be exactly the statement-head flags Lenient turns off",
        );
    }
}