Skip to main content

squonk_ast/dialect/
conflict.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! Feature-set self-consistency registries. Three sibling checkers surface the
5//! combinations the per-feature model cannot make independent:
6//!
7//! - [`FeatureSet::lexical_conflict`] — two features that both claim the *same*
8//!   context-free tokenizer trigger (a soundness hazard: fixed precedence silently
9//!   *mis-parses* one of them).
10//! - [`FeatureSet::feature_dependencies`] — a grammar flag set without the base flag it
11//!   rides on (an *inertness* hazard: the dependent flag is simply unreachable, never a
12//!   wrong parse).
13//! - [`FeatureSet::grammar_conflict`] — two features that both read the *same*
14//!   parser-position head (a soundness hazard like a lexical conflict, but one layer up: a
15//!   fixed parser branch order silently shadows one reading, with the tokenizer innocent).
16//!
17//! The three are MECE by construction: a lexical trigger never appears in the dependency or
18//! grammar registries, a pure grammar dependency never appears in the other two, and a
19//! parser-position contention has no tokenizer trigger of its own and rides no base flag.
20//! All are test/debug-time properties — every shipped preset and any custom [`FeatureDelta`]
21//! should be clean under all three.
22//!
23//! These registries record the contentions with *no* defined resolution. Their positive
24//! counterpart — the multi-claimant statement heads a preset *does* union with a documented,
25//! deterministic resolution (a lookahead split, a dispatch precedence, or a deliberate
26//! one-reading exclusion), which is why they earn no variant here — is the enumerable
27//! [`MULTI_CLAIMANT_STATEMENT_HEADS`] ledger in the `head_contention` module. A new
28//! head-level `LENIENT` exclusion is recorded there, not as a registry variant.
29
30use super::*;
31
32impl FeatureSet {
33    /// The first lexical conflict in this feature set, or `None` when it is
34    /// self-consistent.
35    ///
36    /// A dialect is modelled as independent feature data, but a few features
37    /// contend for the *same* tokenizer trigger, and the tokenizer is context-free:
38    /// each trigger resolves to exactly one token kind by a fixed precedence. Enabling
39    /// two claimants of one trigger is not an error today — the precedence silently
40    /// keeps one and shadows the other (e.g. `double_quoted_strings` on top of a `"`
41    /// identifier quote makes `"x"` a string, never the identifier). This predicate
42    /// turns that implicit precedence into an explicit, testable property: every
43    /// shipped preset — and any custom [`FeatureDelta`] — should return `None`.
44    ///
45    /// The contested triggers form a MECE partition: each [`LexicalConflict`] variant
46    /// governs one distinct trigger — `"`, `[`, `:`+identifier (the `a[x:y]` slice
47    /// bound / `{a: b}` collection separator), `$`+digit, `#` (whose claimants — a line
48    /// comment, the XOR operator, a `#`-led identifier byte class, and DuckDB's `#n`
49    /// positional reference — pair up as comment-vs-identifier, XOR-vs-comment,
50    /// positional-vs-XOR, and positional-vs-comment, none of which both fire in a shipped
51    /// preset), `@name`, `<@`, `?` (the `jsonb` key-existence operator vs the anonymous
52    /// placeholder), and `@@` (the `jsonb`/text-search match operator vs the MySQL
53    /// system-variable sigil) — with no overlap, plus the byte-class
54    /// hygiene variants that keep a feature-claimed sigil byte (`$`, `@`, `:`) out of a
55    /// custom table's identifier-start class. The set is exhaustive over the tokenizer's
56    /// *shared-trigger* hazards. The other either/or features are already single-valued
57    /// *by type* — [`PipeOperator`] for `||` and [`DoubleAmpersand`] for `&&` are enums,
58    /// not overlapping booleans, so an invalid combination is unrepresentable and needs
59    /// no runtime check. The remaining multi-meaning sigils are disambiguated by
60    /// lookahead, not by enabling rival features, so they are MECE by context: `::`
61    /// (typecast) and a lone `:` before a non-identifier byte stay disjoint from `:name`
62    /// by their follow byte — only a `:` before a bare identifier (the `a[x:y]` slice
63    /// bound / `{a: b}` collection separator) contends,
64    /// which is the tracked variant above; `@@` is disjoint from `@name` by its second
65    /// `@` (its only contention is the `jsonb`-vs-system-variable trigger above), and the
66    /// `@?` operator is disjoint from every `@` claimant by its second byte; the non-digit
67    /// `$` forms and the `_charset'…'` introducer split by their own
68    /// follow-sets; `/*!` (a versioned-comment region under
69    /// [`CommentSyntax::versioned_comments`]) is disjoint from a plain `/*` block
70    /// comment by its third byte, so the two never contend for the `/*` trigger; and the
71    /// two `U&`-prefixed [`StringLiteralSyntax::unicode_strings`] surfaces — the `U&'…'`
72    /// string constant and the `U&"…"` delimited identifier — are disjoint from each
73    /// other by their third byte (`'` vs `"`) and from a plain `U`-led identifier by the
74    /// three-byte `U&'`/`U&"` lead (`U` is an ordinary identifier-start letter, so a bare
75    /// `U&1` stays a `U` word, a `&` operator, and `1`), so neither steals the other nor a
76    /// plain identifier and no variant is minted for them.
77    ///
78    /// Grammar-level gates ([`MutationSyntax`], [`StatementDdlGates`], [`SelectSyntax`],
79    /// [`TableExpressionSyntax`], …) never *contend for a trigger* — each is introduced by
80    /// a disjoint keyword (or occupies a distinct clause position) with no ordering
81    /// contention, so enabling any combination resolves to one production. They have their
82    /// own, MECE-disjoint hazard instead: a *dependency*, where a refinement flag rides a
83    /// base flag and is inert without it. That family is the sibling
84    /// [`feature_dependencies`](Self::feature_dependencies) registry; contention here stays
85    /// a purely *lexical* property covering only shared tokenizer triggers.
86    pub const fn lexical_conflict(&self) -> Option<LexicalConflict> {
87        // `"`: a string constant and a quoted identifier cannot both claim the byte.
88        if self.string_literals.double_quoted_strings
89            && identifier_quotes_open_with(self.identifier_quotes, '"')
90        {
91            return Some(LexicalConflict::DoubleQuoteStringVersusIdentifier);
92        }
93        // `[`: a bracket identifier quote claims `[` at lex time, so the `[`-punctuation
94        // expression grammar (subscript, array constructor, DuckDB list literal) — and the
95        // table-position PartiQL / SUPER path root (`FROM src[0].a`) — can never receive it.
96        if identifier_quotes_open_with(self.identifier_quotes, '[')
97            && (self.expression_syntax.subscript
98                || self.expression_syntax.array_constructor
99                || self.expression_syntax.collection_literals
100                || self.table_expressions.table_json_path)
101        {
102            return Some(LexicalConflict::BracketIdentifierVersusArraySyntax);
103        }
104        // `:`+identifier: a colon-named parameter, array slicing with a bare-identifier
105        // upper bound, and a collection literal's `key: value` separator before a
106        // bare-identifier value all claim it. The `:name` scanner binds `:`+identifier
107        // as one parameter, so inside `a[x:y]` it swallows `:y` (the slice's upper
108        // bound), inside `{a: b}` it swallows `:b` (the entry's value), and inside
109        // `a:b` it swallows the path key; a feature set enabling any of those grammars
110        // with colon parameters must pick one meaning.
111        // (`::` and a lone `:` before a non-identifier byte stay the typecast and
112        // separator regardless.)
113        if self.parameters.named_colon
114            && (self.expression_syntax.subscript
115                || self.expression_syntax.collection_literals
116                || self.expression_syntax.semi_structured_access)
117        {
118            return Some(LexicalConflict::ColonParameterVersusSliceBound);
119        }
120        // `$`+digit: a money literal and a positional parameter cannot both claim it
121        // (the scanner tries money first, shadowing the parameter).
122        if self.numeric_literals.money_literals && self.parameters.positional_dollar {
123            return Some(LexicalConflict::MoneyVersusPositionalDollar);
124        }
125        // `$`+identifier-start: a SQLite `$name` parameter and a PostgreSQL
126        // `$tag$…$tag$` dollar-quote opener both lead with `$` then a tag/identifier
127        // byte (the classes overlap), so a feature set enabling both resolves the byte
128        // by fixed scan precedence, shadowing one. `$`+digit stays disjoint (that is
129        // the money-vs-positional trigger above), so only the identifier-lead contends.
130        if self.parameters.named_dollar && self.string_literals.dollar_quoted_strings {
131            return Some(LexicalConflict::NamedDollarParameterVersusDollarQuotedString);
132        }
133        // `#`: a line comment and a `#`-led identifier cannot both claim it (the comment
134        // branch wins, so a `#`-start byte class never lexes a `#name` word).
135        if self.comment_syntax.line_comment_hash
136            && self
137                .byte_classes
138                .has_class(b'#', lex_class::CLASS_IDENTIFIER_START)
139        {
140            return Some(LexicalConflict::HashCommentVersusHashIdentifier);
141        }
142        // `#`: PostgreSQL's bitwise-XOR operator and a `#` line comment cannot both claim
143        // it (the comment is skipped as trivia before the operator scan can see `#`, so the
144        // XOR operator is silently shadowed). The third claimant of the `#` trigger, after
145        // the comment-vs-identifier pair above.
146        if self.hash_bitwise_xor && self.comment_syntax.line_comment_hash {
147            return Some(LexicalConflict::HashXorOperatorVersusHashComment);
148        }
149        // `#`: DuckDB's `#n` positional column reference and PostgreSQL's `#` bitwise-XOR
150        // operator both claim the `#` trigger. The positional scan arm is placed before
151        // the XOR arm, so `#`+digit lexes as a positional reference, silently shadowing
152        // the XOR reading of that byte. The fourth claimant of the `#` trigger.
153        if self.expression_syntax.positional_column && self.hash_bitwise_xor {
154            return Some(LexicalConflict::HashXorOperatorVersusPositionalColumn);
155        }
156        // `#`: DuckDB's `#n` positional column reference and a `#` line comment both claim
157        // the `#` trigger. The comment is consumed as trivia before the positional scan
158        // sees `#`, silently shadowing the `#n` reference, so a feature set enabling both
159        // must pick one meaning for `#`. (A `#`-led identifier byte class instead resolves
160        // by scan order — the identifier scan precedes the positional arm — so it does not
161        // contend here, mirroring the XOR-vs-identifier case.)
162        if self.expression_syntax.positional_column && self.comment_syntax.line_comment_hash {
163            return Some(LexicalConflict::HashCommentVersusPositionalColumn);
164        }
165        // `@name`: a named-at parameter and a user-variable read cannot both claim it
166        // (the scanner tries the user variable first, shadowing the parameter). The
167        // `@@` system-variable form is disjoint from both — it needs a second `@` —
168        // so it never enters this conflict.
169        if self.parameters.named_at && self.session_variables.user_variables {
170            return Some(LexicalConflict::AtNameParameterVersusUserVariable);
171        }
172        // `<@`: PostgreSQL's "contained by" operator and an abutting `@name` sigil both
173        // claim `<`+`@`. The scanner munches `<@` to the containment operator whenever
174        // containment is on, shadowing the abutting `a<@x` (meaning `a < @x`) that a
175        // `@name` parameter or user-variable read would otherwise lex. (`@>` is safe —
176        // its second byte `>` is not identifier-start — so only `<@` contends.)
177        if self.operator_syntax.containment_operators
178            && (self.parameters.named_at || self.session_variables.user_variables)
179        {
180            return Some(LexicalConflict::ContainmentOperatorVersusAtName);
181        }
182        // `?`: PostgreSQL's `jsonb` key-existence operator (also the lead byte of `?|`/`?&`)
183        // and the anonymous `?` placeholder both claim the byte. The anonymous-parameter
184        // dispatch arm precedes the operator arm, so `?` lexes as the placeholder whenever
185        // that is on, silently shadowing the operator. No shipped preset pairs them
186        // (PostgreSQL enables the operators and has no `?` parameter; the placeholder
187        // dialects leave the operators off).
188        if self.operator_syntax.jsonb_operators && self.parameters.anonymous_question {
189            return Some(LexicalConflict::JsonbKeyExistsVersusAnonymousParameter);
190        }
191        // `@@`: PostgreSQL's `jsonb`/text-search match operator and MySQL's `@@name`
192        // system-variable sigil both claim `@`+`@`. The system-variable dispatch arm
193        // precedes the operator arm, so `@@name` lexes as the variable whenever that is on,
194        // silently shadowing the operator. (`@?` is disjoint from every `@` claimant by its
195        // second byte, so it never contends.) No shipped preset pairs them.
196        if self.operator_syntax.jsonb_operators && self.session_variables.system_variables {
197            return Some(LexicalConflict::JsonbSearchOperatorVersusSystemVariable);
198        }
199        // `@`: the general bare-`@` operator (`custom_operators`) and an abutting `@name`
200        // sigil (`named_at`/`user_variables`) both claim `@`+identifier. The sigil dispatch
201        // arms precede the bare-`@` operator arm, so `@x` lexes as the sigil, shadowing the
202        // operator. (The `<@`/`@@` two-byte triggers are the containment/jsonb pairs above;
203        // this is the single `@`.) No shipped preset pairs them.
204        if self.operator_syntax.custom_operators
205            && (self.parameters.named_at || self.session_variables.user_variables)
206        {
207            return Some(LexicalConflict::CustomOperatorVersusAtName);
208        }
209        // `@@`: the general `@@` operator (`custom_operators`, when the `jsonb` family is off
210        // so `@@` is not already claimed by the pair above) and MySQL's `@@name` system
211        // variable both claim `@`+`@`. The system-variable arm precedes the operator arm, so
212        // `@@name` lexes as the variable, shadowing the operator. No shipped preset pairs them.
213        if self.operator_syntax.custom_operators
214            && !self.operator_syntax.jsonb_operators
215            && self.session_variables.system_variables
216        {
217            return Some(LexicalConflict::CustomOperatorVersusSystemVariable);
218        }
219        // Byte-class hygiene: a feature that leads with a sigil byte assumes that byte
220        // dispatches to its sigil scan, so a custom `ByteClasses` must not also mark the
221        // byte `CLASS_IDENTIFIER_START` — the same either/or the `#` check above enforces
222        // for a `#`-led comment. Every shipped preset uses a table that marks none of these
223        // bytes identifier-start (`STANDARD_BYTE_CLASSES`, or `POSTGRES_BYTE_CLASSES` which
224        // only adds the vertical tab to the whitespace class), so only a custom table trips
225        // these.
226        if (self.string_literals.dollar_quoted_strings
227            || self.parameters.positional_dollar
228            || self.numeric_literals.money_literals)
229            && self
230                .byte_classes
231                .has_class(b'$', lex_class::CLASS_IDENTIFIER_START)
232        {
233            return Some(LexicalConflict::DollarSigilVersusIdentifierByte);
234        }
235        if (self.parameters.named_at
236            || self.session_variables.user_variables
237            || self.session_variables.system_variables)
238            && self
239                .byte_classes
240                .has_class(b'@', lex_class::CLASS_IDENTIFIER_START)
241        {
242            return Some(LexicalConflict::AtSigilVersusIdentifierByte);
243        }
244        if self.parameters.named_colon
245            && self
246                .byte_classes
247                .has_class(b':', lex_class::CLASS_IDENTIFIER_START)
248        {
249            return Some(LexicalConflict::ColonSigilVersusIdentifierByte);
250        }
251        None
252    }
253
254    /// Whether this feature set has no [`lexical_conflict`](Self::lexical_conflict) —
255    /// every shared tokenizer trigger has exactly one claimant.
256    pub const fn is_lexically_consistent(&self) -> bool {
257        self.lexical_conflict().is_none()
258    }
259
260    /// The first grammar-dependency violation in this feature set — a refinement flag set
261    /// without the base flag it rides on — or `None` when every dependent flag has its
262    /// prerequisite.
263    ///
264    /// The grammar sibling of [`lexical_conflict`](Self::lexical_conflict). Several flags
265    /// only *refine* a production another flag opens: the extra slice bound needs the
266    /// bracket subscript, the `MERGE` extensions need `MERGE` dispatch, the `[FORCE]
267    /// CHECKPOINT <db>` operands need the bare `CHECKPOINT` statement, and so on. Each
268    /// documents the relationship in its field doc ("rides on" / "only reachable where" /
269    /// "inert without"); this predicate turns that prose into an explicit, testable
270    /// property.
271    ///
272    /// The severity is *inertness*, not unsoundness: a dependent flag whose base is off is
273    /// simply unreachable — the parser never reaches the grammar position, so the flag has
274    /// no effect and no wrong parse results. That is why this is a debug/test-time property
275    /// (every shipped preset and any custom [`FeatureDelta`] should return `None`) rather
276    /// than a runtime reject, and why [`try_with`](Self::try_with) — whose contract is the
277    /// *lexical*-soundness gate — deliberately does not fold it in.
278    ///
279    /// MECE with [`lexical_conflict`](Self::lexical_conflict): every variant here is a pure
280    /// grammar dependency with no tokenizer trigger of its own, and no shared-trigger
281    /// hazard appears here. A flag that both rides a base grammar flag *and* claims a
282    /// tokenizer trigger belongs in each registry for its respective axis.
283    ///
284    /// The predicate reads the *whole* [`FeatureSet`], so a dependent flag may name a base on
285    /// a different syntax axis (a cross-axis dependency) as freely as a same-axis one; the
286    /// machinery does not restrict a dependency to one sub-struct. What it *does* require is
287    /// the inertness contract above — a registrable dependent must be fully inert when its
288    /// base is off. That requirement, not the axis, is what excludes the one measured
289    /// near-miss:
290    ///
291    /// * [`SessionVariableSyntax::variable_assignment`] — a two-facet flag.
292    ///   Its *parser* facet (the MySQL `SET` variable-assignment grammar) is unreachable
293    ///   without [`ShowSyntax::session_statements`], which gates all `SET`/`RESET`/`SHOW`
294    ///   dispatch — a genuine cross-axis grammar dependency. But its *lexer* facet (`:=`
295    ///   munching to one `ColonEquals` operator token) fires independently of that gate, so
296    ///   the flag is **not** inert when `session_statements` is off: it still changes
297    ///   tokenization (measured — `:=` lexes to one token with the flag on vs two, `:` then
298    ///   `=`, with it off). A dependent that observably changes tokenization while its base is
299    ///   off violates the inertness contract *and* the "no tokenizer trigger of its own" MECE
300    ///   line, so registering it as a variant would falsely certify it safe-to-dangle.
301    ///   `variable_assignment` is therefore a documented exemption, not a variant: its field
302    ///   doc names the independent lexer facet as the reason the flag is not fully inert, and
303    ///   its cross-axis parser dependency is recorded there rather than here.
304    pub const fn feature_dependencies(&self) -> Option<FeatureDependencyViolation> {
305        use FeatureDependencyViolation as V;
306
307        // QueryTailSyntax: the row-locking refinements ride the base `locking_clauses` gate
308        // (they refine the strength keyword / repeat the shared clause).
309        if self.query_tail_syntax.key_lock_strengths && !self.query_tail_syntax.locking_clauses {
310            return Some(V::KeyLockStrengthsWithoutLockingClauses);
311        }
312        if self.query_tail_syntax.stacked_locking_clauses && !self.query_tail_syntax.locking_clauses
313        {
314            return Some(V::StackedLockingClausesWithoutLockingClauses);
315        }
316
317        // TableFactorSyntax: the `WITH OFFSET` tail rides an `UNNEST` table factor.
318        if self.table_factor_syntax.unnest_with_offset && !self.table_factor_syntax.unnest {
319            return Some(V::UnnestWithOffsetWithoutUnnest);
320        }
321
322        // ExpressionSyntax: the third slice bound rides the bracket `subscript`; the
323        // multidimensional bare-bracket sub-row rides the `ARRAY[…]` constructor.
324        if self.expression_syntax.slice_step && !self.expression_syntax.subscript {
325            return Some(V::SliceStepWithoutSubscript);
326        }
327        if self.expression_syntax.multidim_array_literals
328            && !self.expression_syntax.array_constructor
329        {
330            return Some(V::MultidimArrayLiteralsWithoutArrayConstructor);
331        }
332
333        // OperatorSyntax: the list-operand and arbitrary-operator quantifier forms ride the
334        // base `quantified_comparisons` reading of `ANY`/`ALL`/`SOME`; the DuckDB lambda is
335        // inert without the `->` lexeme `json_arrow_operators` munches.
336        if self.operator_syntax.quantified_comparison_lists
337            && !self.operator_syntax.quantified_comparisons
338        {
339            return Some(V::QuantifiedComparisonListsWithoutQuantifiedComparisons);
340        }
341        if self.operator_syntax.quantified_arbitrary_operator
342            && !self.operator_syntax.quantified_comparisons
343        {
344            return Some(V::QuantifiedArbitraryOperatorWithoutQuantifiedComparisons);
345        }
346        if self.operator_syntax.lambda_expressions && !self.operator_syntax.json_arrow_operators {
347            return Some(V::LambdaExpressionsWithoutJsonArrowOperators);
348        }
349
350        // MutationSyntax: the CTE-before-MERGE clause and the three MERGE-action extensions
351        // are only reachable where `merge` dispatches `MERGE` at all.
352        if self.mutation_syntax.cte_before_merge && !self.mutation_syntax.merge {
353            return Some(V::CteBeforeMergeWithoutMerge);
354        }
355        if self.mutation_syntax.merge_when_not_matched_by && !self.mutation_syntax.merge {
356            return Some(V::MergeWhenNotMatchedByWithoutMerge);
357        }
358        if self.mutation_syntax.merge_insert_default_values && !self.mutation_syntax.merge {
359            return Some(V::MergeInsertDefaultValuesWithoutMerge);
360        }
361        if self.mutation_syntax.merge_insert_overriding && !self.mutation_syntax.merge {
362            return Some(V::MergeInsertOverridingWithoutMerge);
363        }
364
365        // IndexAlterSyntax: the extended-`ALTER TABLE` actions and guards are only
366        // reachable through the `alter_table_extended` path.
367        if self.index_alter_syntax.alter_existence_guards
368            && !self.index_alter_syntax.alter_table_extended
369        {
370            return Some(V::AlterExistenceGuardsWithoutAlterTableExtended);
371        }
372        if self.index_alter_syntax.alter_column_set_data_type
373            && !self.index_alter_syntax.alter_table_extended
374        {
375            return Some(V::AlterColumnSetDataTypeWithoutAlterTableExtended);
376        }
377
378        // UtilitySyntax: each operand/guard refinement rides the base statement gate.
379        if self.maintenance_syntax.checkpoint_database && !self.maintenance_syntax.checkpoint {
380            return Some(V::CheckpointDatabaseWithoutCheckpoint);
381        }
382        if self.maintenance_syntax.analyze_columns && !self.maintenance_syntax.analyze {
383            return Some(V::AnalyzeColumnsWithoutAnalyze);
384        }
385        if self.utility_syntax.load_bare_name && !self.utility_syntax.load_extension {
386            return Some(V::LoadBareNameWithoutLoadExtension);
387        }
388        if self.utility_syntax.call_bare_name && !self.utility_syntax.call {
389            return Some(V::CallBareNameWithoutCall);
390        }
391        if self.utility_syntax.detach_if_exists && !self.utility_syntax.attach {
392            return Some(V::DetachIfExistsWithoutAttach);
393        }
394        if self.utility_syntax.use_qualified_name && !self.utility_syntax.use_statement {
395            return Some(V::UseQualifiedNameWithoutUseStatement);
396        }
397        if self.access_control_syntax.access_control_extended_objects
398            && !self.access_control_syntax.access_control
399        {
400            return Some(V::AccessControlExtendedObjectsWithoutAccessControl);
401        }
402        if self.access_control_syntax.access_control_account_grants
403            && !self.access_control_syntax.access_control
404        {
405            return Some(V::AccountGrantsWithoutAccessControl);
406        }
407        if self.utility_syntax.prepare_typed_parameters && !self.utility_syntax.prepared_statements
408        {
409            return Some(V::PrepareTypedParametersWithoutPreparedStatements);
410        }
411
412        None
413    }
414
415    /// Whether this feature set has no
416    /// [`feature_dependencies`](Self::feature_dependencies) violation — every dependent
417    /// grammar flag has the base flag it rides on.
418    pub const fn has_satisfied_feature_dependencies(&self) -> bool {
419        self.feature_dependencies().is_none()
420    }
421
422    /// This feature set with every *inert* refinement flag cleared — the dependency
423    /// registry's normal form, in which
424    /// [`has_satisfied_feature_dependencies`](Self::has_satisfied_feature_dependencies)
425    /// holds.
426    ///
427    /// Each [`feature_dependencies`](Self::feature_dependencies) violation is a refinement
428    /// flag enabled without the base it rides on; the registry's own contract is that such
429    /// a flag is *inert* (the parser never reaches its grammar position), so turning it off
430    /// cannot change any parse. This walks the registry, clearing one dangling dependent
431    /// per step until none remain, and is therefore an outcome-preserving projection onto
432    /// the parser's self-consistency precondition (the parse-entry `debug_assert!`) — the
433    /// tool a caller that assembles a [`FeatureDelta`] by toggling individual flags uses to
434    /// drop the inert leftovers before handing the set to the parser, rather than reasoning
435    /// out the dependency closure by hand.
436    ///
437    /// This is the dependency sibling of the lexical [`try_with`](Self::try_with): where
438    /// `try_with` reports the lexical verdict as a value, this repairs the (benign)
439    /// dependency one. It deliberately does *not* touch a
440    /// [`lexical_conflict`](Self::lexical_conflict) or a
441    /// [`grammar_conflict`](Self::grammar_conflict) — those are *soundness* hazards with no
442    /// inert-and-safe reading to normalize away, so a set carrying one has no defined parse
443    /// and this returns it unchanged.
444    pub fn without_dangling_dependents(&self) -> FeatureSet {
445        use FeatureDependencyViolation as V;
446
447        let mut features = self.clone();
448        // Each step clears exactly the dependent named by the first violation, so the
449        // violation count strictly decreases and the loop terminates.
450        while let Some(violation) = features.feature_dependencies() {
451            match violation {
452                V::KeyLockStrengthsWithoutLockingClauses => {
453                    features.query_tail_syntax.key_lock_strengths = false;
454                }
455                V::StackedLockingClausesWithoutLockingClauses => {
456                    features.query_tail_syntax.stacked_locking_clauses = false;
457                }
458                V::UnnestWithOffsetWithoutUnnest => {
459                    features.table_factor_syntax.unnest_with_offset = false;
460                }
461                V::SliceStepWithoutSubscript => {
462                    features.expression_syntax.slice_step = false;
463                }
464                V::MultidimArrayLiteralsWithoutArrayConstructor => {
465                    features.expression_syntax.multidim_array_literals = false;
466                }
467                V::QuantifiedComparisonListsWithoutQuantifiedComparisons => {
468                    features.operator_syntax.quantified_comparison_lists = false;
469                }
470                V::QuantifiedArbitraryOperatorWithoutQuantifiedComparisons => {
471                    features.operator_syntax.quantified_arbitrary_operator = false;
472                }
473                V::LambdaExpressionsWithoutJsonArrowOperators => {
474                    features.operator_syntax.lambda_expressions = false;
475                }
476                V::CteBeforeMergeWithoutMerge => {
477                    features.mutation_syntax.cte_before_merge = false;
478                }
479                V::MergeWhenNotMatchedByWithoutMerge => {
480                    features.mutation_syntax.merge_when_not_matched_by = false;
481                }
482                V::MergeInsertDefaultValuesWithoutMerge => {
483                    features.mutation_syntax.merge_insert_default_values = false;
484                }
485                V::MergeInsertOverridingWithoutMerge => {
486                    features.mutation_syntax.merge_insert_overriding = false;
487                }
488                V::AlterExistenceGuardsWithoutAlterTableExtended => {
489                    features.index_alter_syntax.alter_existence_guards = false;
490                }
491                V::AlterColumnSetDataTypeWithoutAlterTableExtended => {
492                    features.index_alter_syntax.alter_column_set_data_type = false;
493                }
494                V::CheckpointDatabaseWithoutCheckpoint => {
495                    features.maintenance_syntax.checkpoint_database = false;
496                }
497                V::AnalyzeColumnsWithoutAnalyze => {
498                    features.maintenance_syntax.analyze_columns = false;
499                }
500                V::LoadBareNameWithoutLoadExtension => {
501                    features.utility_syntax.load_bare_name = false;
502                }
503                V::CallBareNameWithoutCall => {
504                    features.utility_syntax.call_bare_name = false;
505                }
506                V::DetachIfExistsWithoutAttach => {
507                    features.utility_syntax.detach_if_exists = false;
508                }
509                V::UseQualifiedNameWithoutUseStatement => {
510                    features.utility_syntax.use_qualified_name = false;
511                }
512                V::AccessControlExtendedObjectsWithoutAccessControl => {
513                    features
514                        .access_control_syntax
515                        .access_control_extended_objects = false;
516                }
517                V::AccountGrantsWithoutAccessControl => {
518                    features.access_control_syntax.access_control_account_grants = false;
519                }
520                V::PrepareTypedParametersWithoutPreparedStatements => {
521                    features.utility_syntax.prepare_typed_parameters = false;
522                }
523            }
524        }
525        features
526    }
527
528    /// The first grammar-position mutual exclusion in this feature set — two features whose
529    /// grammars read the *same* token sequence at the *same* parser-position head with no
530    /// lookahead to tell them apart — or `None` when no such pair is enabled together.
531    ///
532    /// The third self-consistency sibling, after
533    /// [`lexical_conflict`](Self::lexical_conflict) (shared *tokenizer* triggers) and
534    /// [`feature_dependencies`](Self::feature_dependencies) (a refinement flag without its
535    /// base). This one covers the class the other two cannot: two features whose grammars
536    /// claim the same head, where the tokenizer is innocent (each byte lexes to one fixed
537    /// token) and neither flag rides the other. The parser resolves the head by a fixed
538    /// branch order, so enabling both silently shadows one reading — a *soundness* hazard
539    /// like a lexical conflict but at the grammar layer, which is exactly why neither sibling
540    /// registry can catch it (the [`prefix_colon_alias`](SelectSyntax::prefix_colon_alias)
541    /// field doc first named this gap).
542    ///
543    /// A collision is registrable here only when the contention has *no defined resolution*
544    /// and *no shipped preset enables both*. A preset that pairs two head-claimants with a
545    /// documented, deterministic resolution — DuckDB's `SUMMARIZE`-vs-MySQL-`DESCRIBE`
546    /// dispatch order under Lenient, or the `GROUP BY ALL` lookahead split — is a
547    /// conflict-*free* permissive union, not a mutual exclusion, and gets no variant. Like
548    /// the siblings this is a debug/test-time property — every shipped preset and any custom
549    /// [`FeatureDelta`] should return `None`.
550    ///
551    /// MECE with both siblings: every variant here is a pure parser-position contention —
552    /// no shared tokenizer trigger (that is a [`LexicalConflict`]) and no base-flag
553    /// dependency (that is a [`FeatureDependencyViolation`]).
554    pub const fn grammar_conflict(&self) -> Option<GrammarConflict> {
555        use GrammarConflict as G;
556
557        // `<ident> :` head: DuckDB's prefix colon alias (`SELECT j : 42` / `FROM b : a`, the
558        // alias written before its value) and semi-structured access (`base : key`, a postfix
559        // path) both read a bare identifier then `:` at a value / select-item head. The `:`
560        // always lexes as a lone `Colon` punctuation token (no tokenizer trigger), and
561        // neither flag rides the other, so the hazard is purely grammatical: the prefix-alias
562        // branch is tried first, binding `a : b` as an alias and silently shadowing the path
563        // reading. No shipped preset pairs them (DuckDB/Lenient enable the prefix alias with
564        // `semi_structured_access` off; Snowflake/Databricks enable the path with
565        // `prefix_colon_alias` off).
566        if self.select_syntax.prefix_colon_alias && self.expression_syntax.semi_structured_access {
567            return Some(G::PrefixColonAliasVersusSemiStructuredAccess);
568        }
569
570        // Leading `DO` head: PostgreSQL's anonymous-code-block statement
571        // ([`do_statement`](UtilitySyntax::do_statement)) and MySQL's evaluate-and-discard
572        // expression list ([`do_expression_list`](UtilitySyntax::do_expression_list)) both
573        // dispatch on a bare leading `DO`. The `DO` byte lexes to one contextual keyword (no
574        // tokenizer trigger), and neither flag rides the other, so the hazard is purely
575        // grammatical: the code-block branch is tried first, so `DO 'x'` (MySQL intent) mis-parses
576        // as a PG block body and `DO 1, 2` over-rejects. No shipped preset pairs them (PostgreSQL
577        // and Lenient arm the block with the expression list off; MySQL the reverse).
578        if self.utility_syntax.do_statement && self.utility_syntax.do_expression_list {
579            return Some(G::DoStatementVersusDoExpressionList);
580        }
581
582        // Leading `PREPARE`/`EXECUTE`/`DEALLOCATE` head: DuckDB's typed-`AS` lifecycle
583        // ([`prepared_statements`](UtilitySyntax::prepared_statements)) and MySQL's
584        // `FROM`/`USING` lifecycle
585        // ([`prepared_statements_from`](UtilitySyntax::prepared_statements_from)) claim the same
586        // three leading keywords with different grammars. The dispatch resolves the two heads
587        // DuckDB-first, but the `DEALLOCATE` tail resolves MySQL-first (the `PREPARE` keyword is
588        // mandatory whenever `prepared_statements_from` is on), so the combination is incoherent
589        // across one lifecycle — see the parser sites in `query.rs`/`util.rs`. No shipped preset
590        // pairs them (DuckDB/PostgreSQL/Lenient arm the typed-`AS` form; MySQL the `FROM`/`USING`
591        // form). Registry-rejecting the combination is what lets the three keyword sites leave the
592        // both-on semantics undefined.
593        if self.utility_syntax.prepared_statements && self.utility_syntax.prepared_statements_from {
594            return Some(G::PreparedStatementsVersusPreparedStatementsFrom);
595        }
596
597        // `GRANT`/`REVOKE` head, a ROUTE-flag conflict (see the enum doc): the MySQL account route
598        // ([`access_control_account_grants`](AccessControlSyntax::access_control_account_grants))
599        // dispatches the whole account-based grammar before the standard/PostgreSQL extended-object
600        // grammar ([`access_control_extended_objects`](AccessControlSyntax::access_control_extended_objects))
601        // is consulted, so enabling both silently deadens the extended-object reading. The two are
602        // independent intents (a custom delta can set both), and the route's branch order picks the
603        // account grammar with no lookahead — a contradiction with no defined resolution. No shipped
604        // preset pairs them: MySQL arms the account route with extended objects off; ANSI/PostgreSQL/
605        // DuckDB/Lenient keep the extended-object grammar with the account route off.
606        if self.access_control_syntax.access_control_account_grants
607            && self.access_control_syntax.access_control_extended_objects
608        {
609            return Some(G::AccountGrantsVersusExtendedObjects);
610        }
611
612        None
613    }
614
615    /// Whether this feature set has no [`grammar_conflict`](Self::grammar_conflict) — no two
616    /// features contend for the same parser-position head.
617    pub const fn has_no_grammar_conflict(&self) -> bool {
618        self.grammar_conflict().is_none()
619    }
620
621    /// Apply `delta` to this base, returning the customized set only when it is
622    /// lexically consistent, or the first [`LexicalConflict`] it would introduce.
623    ///
624    /// The checked counterpart to [`with`](Self::with): a builder that wants the
625    /// conflict verdict as a *value* uses `try_with`, while [`with`](Self::with) stays
626    /// the documented-unchecked fast path the presets build on (its result feeds the
627    /// construction-time `debug_assert!` at the parse/tokenize entry seam). See
628    /// [`lexical_conflict`](Self::lexical_conflict) for what the shared triggers are.
629    ///
630    /// This is a *lexical* gate only — its contract is the tokenizer-soundness check, and
631    /// it does not verify the sibling [`feature_dependencies`](Self::feature_dependencies)
632    /// property (a dependent grammar flag without its base is inert, not unsound, so it
633    /// need not block a `try_with`). A builder wanting both verdicts checks
634    /// [`feature_dependencies`](Self::feature_dependencies) on the returned set.
635    pub const fn try_with(&self, delta: FeatureDelta) -> Result<Self, LexicalConflict> {
636        let candidate = self.with(delta);
637        match candidate.lexical_conflict() {
638            Some(conflict) => Err(conflict),
639            None => Ok(candidate),
640        }
641    }
642}
643
644/// Whether any style in `quotes` opens with `open` (a `const` membership test, so the
645/// [`FeatureSet::lexical_conflict`] checks fold at compile time).
646const fn identifier_quotes_open_with(quotes: &[IdentifierQuote], open: char) -> bool {
647    let mut index = 0;
648    while index < quotes.len() {
649        if quotes[index].open() == open {
650            return true;
651        }
652        index += 1;
653    }
654    false
655}
656
657/// A mutual exclusion between two features that both claim the *same* context-free
658/// tokenizer trigger — surfaced by [`FeatureSet::lexical_conflict`].
659///
660/// These are the dialect-data combinations the per-feature model cannot make
661/// independent: the tokenizer resolves each trigger byte to one token kind, so two
662/// claimants of one trigger are mutually exclusive even though each is its own feature
663/// field. The variants partition the contested triggers MECE — one per trigger, no
664/// overlap.
665///
666/// # Adding a tokenizer-trigger feature?
667///
668/// A feature that makes the scanner branch on a byte (a new sigil, quote, comment, or
669/// operator lead) must be audited against this registry before it lands — the
670/// "MECE and exhaustive" claim is hand-maintained, and both the `:`-slice and `<@`
671/// hazards postdated an earlier version of it:
672/// 1. Identify the lead byte(s) the feature's scan arm dispatches on.
673/// 2. For each, list every *other* feature whose scan arm can lead with the same byte
674///    (grep `scan.rs` for the byte and for its `features.` guards).
675/// 3. If two can be enabled at once and the scanner resolves the byte to one by fixed
676///    precedence, add a variant here and a per-variant detection test — never rely on
677///    "no real dialect pairs them", which is exactly how untracked hazards accrue.
678/// 4. If the byte instead stays disambiguated by lookahead (a second fixed byte or a
679///    required follow-set), record *that* in the [`lexical_conflict`] doc's
680///    lookahead-disjoint list rather than minting a variant.
681///
682/// Also confirm the lead byte is not marked [`CLASS_IDENTIFIER_START`] by any preset's
683/// [`ByteClasses`] (the sigil-vs-identifier-byte variants below cover a *custom* table).
684///
685/// [`lexical_conflict`]: FeatureSet::lexical_conflict
686/// [`CLASS_IDENTIFIER_START`]: crate::dialect::lex_class::CLASS_IDENTIFIER_START
687#[derive(Clone, Copy, Debug, PartialEq, Eq)]
688#[non_exhaustive]
689pub enum LexicalConflict {
690    /// `"` is claimed by both [`StringLiteralSyntax::double_quoted_strings`] (→ a
691    /// string constant) and an [`identifier_quotes`](FeatureSet::identifier_quotes)
692    /// style opening with `"` (→ a quoted identifier). The tokenizer makes `"…"` a
693    /// string whenever `double_quoted_strings` is on, silently shadowing the quoted
694    /// identifier, so a feature set must pick one meaning for `"`.
695    DoubleQuoteStringVersusIdentifier,
696    /// `[` is claimed by both an [`identifier_quotes`](FeatureSet::identifier_quotes)
697    /// style opening with `[` (T-SQL bracket identifiers, → a quoted identifier) and
698    /// the `[`-punctuation expression grammar that
699    /// [`ExpressionSyntax::subscript`]/[`array_constructor`](ExpressionSyntax::array_constructor)/[`collection_literals`](ExpressionSyntax::collection_literals)
700    /// need, or the table-position PartiQL / SUPER path root that
701    /// [`TableExpressionSyntax::table_json_path`](TableExpressionSyntax::table_json_path)
702    /// enters on a `[` after a table name (`FROM src[0].a`). The tokenizer claims `[` for the
703    /// identifier before the parser sees `[` punctuation, so those forms (and any other
704    /// `[`-led syntax, such as array-type suffixes or the DuckDB bare-bracket list literal)
705    /// can never fire — pick bracket identifiers *or* `[` expression / table-path syntax.
706    BracketIdentifierVersusArraySyntax,
707    /// `$`+digit is claimed by both [`NumericLiteralSyntax::money_literals`] (T-SQL
708    /// `$1234.56`, → a number) and [`ParameterSyntax::positional_dollar`] (PostgreSQL
709    /// `$1`, → a parameter). The scanner tries money first, silently shadowing the
710    /// positional parameter, so a feature set must pick one meaning for `$`+digit.
711    MoneyVersusPositionalDollar,
712    /// `@name` is claimed by both [`ParameterSyntax::named_at`] (T-SQL, → a named
713    /// parameter placeholder) and [`SessionVariableSyntax::user_variables`] (MySQL, → a
714    /// user-variable read). The two are the same surface with different meaning, so a
715    /// feature set must pick one for `@name`; the scanner tries the user variable first,
716    /// silently shadowing the parameter. The `@@` system-variable form is disjoint from
717    /// both (its second `@` is no identifier byte), so it is not part of this conflict.
718    AtNameParameterVersusUserVariable,
719    /// `$`+identifier-start is claimed by both [`ParameterSyntax::named_dollar`]
720    /// (SQLite `$name`, → a parameter) and [`StringLiteralSyntax::dollar_quoted_strings`]
721    /// (PostgreSQL `$tag$…$tag$`, → a string constant): a dollar-quote tag byte is the
722    /// same class as an identifier-start, so both scan arms lead with `$` then that
723    /// byte. The scanner resolves it by fixed precedence — one shadows the other — so a
724    /// feature set must pick one meaning for `$`+identifier. (`$`+digit stays the
725    /// separate money-vs-positional trigger, disjoint by its follow byte.)
726    NamedDollarParameterVersusDollarQuotedString,
727    /// `#` is claimed by both [`CommentSyntax::line_comment_hash`] (→ a line comment)
728    /// and a [`byte_classes`](FeatureSet::byte_classes) table marking `#` an
729    /// identifier-start byte (→ a `#name` word). The comment branch wins, so `#` must
730    /// stay out of the identifier-start class wherever it opens a comment.
731    HashCommentVersusHashIdentifier,
732    /// `#` is claimed by both [`FeatureSet::hash_bitwise_xor`] (PostgreSQL, → the bitwise-XOR
733    /// operator) and [`CommentSyntax::line_comment_hash`] (MySQL, → a line comment). The
734    /// comment is consumed as trivia before the operator scanner sees `#`, silently shadowing
735    /// the XOR operator, so a feature set enabling both must pick one meaning for `#`. (A
736    /// `#`-led identifier byte class instead resolves by scan order — the identifier scan
737    /// precedes the XOR arm — so it does not contend here.)
738    HashXorOperatorVersusHashComment,
739    /// `#`+digit is claimed by both [`ExpressionSyntax::positional_column`] (DuckDB `#n`,
740    /// → a positional column reference) and [`FeatureSet::hash_bitwise_xor`] (PostgreSQL, →
741    /// the bitwise-XOR operator, which claims every `#`). The positional scan arm precedes the
742    /// XOR arm, so `#`+digit lexes as the positional reference, silently shadowing the XOR
743    /// reading of that byte — a feature set enabling both must pick one meaning for `#`.
744    /// (No shipped preset pairs them: DuckDB has the positional form and spells nothing
745    /// with `#`-XOR; PostgreSQL has `#`-XOR and no positional form.)
746    HashXorOperatorVersusPositionalColumn,
747    /// `#`+digit is claimed by both [`ExpressionSyntax::positional_column`] (DuckDB `#n`,
748    /// → a positional column reference) and [`CommentSyntax::line_comment_hash`] (MySQL, →
749    /// a line comment, which claims every `#`). The comment is consumed as trivia before
750    /// the positional scan sees `#`, silently shadowing the `#n` reference, so a feature
751    /// set enabling both must pick one meaning for `#`. (This is why Lenient, which keeps
752    /// `#` a line comment, cannot also enable the positional form. A `#`-led identifier
753    /// byte class instead resolves by scan order — the identifier scan precedes the
754    /// positional arm — so it does not contend here, like the XOR case.)
755    HashCommentVersusPositionalColumn,
756    /// `:`+identifier-start is claimed by [`ParameterSyntax::named_colon`]
757    /// (Oracle/SQLite `:name`, → a parameter) and by grammars that write a `:`
758    /// before a bare-identifier operand: array slicing under
759    /// [`ExpressionSyntax::subscript`] (`a[x:y]`, → the slice `:` separator then the
760    /// bound `y`) and the DuckDB collection literals under
761    /// [`ExpressionSyntax::collection_literals`] (`{a: b}` / `MAP {k: v}`, → the
762    /// `key: value` separator then the value), plus semi-structured access under
763    /// [`ExpressionSyntax::semi_structured_access`] (`a:b`, → a path key). The `:name`
764    /// scanner binds `:`+identifier as one parameter token, so it swallows `:y` / `:b`
765    /// and the operand is lost — pick colon-named parameters *or* the `:`-separated
766    /// grammars.
767    /// (`::` stays the typecast and a lone `:` before a non-identifier byte stays the
768    /// separator regardless — only a `:` abutting an identifier contends.)
769    ColonParameterVersusSliceBound,
770    /// `<@` is claimed by both [`OperatorSyntax::containment_operators`] (PostgreSQL
771    /// "contained by", → the `LtAt` operator) and an abutting `@name` sigil —
772    /// [`ParameterSyntax::named_at`] or [`SessionVariableSyntax::user_variables`] (→ a
773    /// `<` operator then an `@name` parameter / user-variable read). The scanner munches
774    /// `<`+`@` to the containment operator whenever containment is on, silently shadowing
775    /// the MySQL-legal abutting `a<@x` (meaning `a < @x`), so a feature set enabling both
776    /// must pick one meaning for `<@`. (`@>` is safe — its second byte `>` is not
777    /// identifier-start — so only `<@` contends.)
778    ContainmentOperatorVersusAtName,
779    /// `?` is claimed by both [`OperatorSyntax::jsonb_operators`] (PostgreSQL `jsonb`
780    /// key-existence operator, and the lead byte of `?|`/`?&`) and
781    /// [`ParameterSyntax::anonymous_question`] (ODBC/JDBC anonymous placeholder). The
782    /// placeholder scan arm precedes the operator arm, so `?` lexes as the placeholder
783    /// whenever it is on, silently shadowing the operator — a feature set enabling both must
784    /// pick one meaning for `?`. (No shipped preset pairs them: PostgreSQL has the operators
785    /// and no `?` parameter; the placeholder dialects leave the operators off.)
786    JsonbKeyExistsVersusAnonymousParameter,
787    /// `@@` is claimed by both [`OperatorSyntax::jsonb_operators`] (PostgreSQL's
788    /// `jsonb`/text-search match operator) and [`SessionVariableSyntax::system_variables`]
789    /// (MySQL `@@name`). The system-variable scan arm precedes the operator arm, so `@@name`
790    /// lexes as the variable whenever it is on, silently shadowing the operator — a feature
791    /// set enabling both must pick one meaning for `@@`. (`@?` is disjoint from every `@`
792    /// claimant by its second byte, so only `@@` contends. No shipped preset pairs them.)
793    JsonbSearchOperatorVersusSystemVariable,
794    /// A bare `@` is claimed by both [`OperatorSyntax::custom_operators`] (the general
795    /// symbolic operator, e.g. the prefix absolute-value `@ x`) and an abutting `@name`
796    /// sigil — [`ParameterSyntax::named_at`] or [`SessionVariableSyntax::user_variables`]
797    /// (→ an `@name` parameter / user-variable read). The sigil dispatch arms precede the
798    /// bare-`@` operator arm, so `@x` lexes as the sigil whenever one is on, silently
799    /// shadowing the operator — a feature set enabling both must pick one meaning for a
800    /// `@`-then-identifier. (A `@ ` not abutting an identifier stays the operator, since the
801    /// sigil arms require an identifier-start follow byte. No shipped preset pairs them:
802    /// PostgreSQL enables the operators and has no `@name` sigil.) Distinct from
803    /// [`ContainmentOperatorVersusAtName`](Self::ContainmentOperatorVersusAtName), which is
804    /// the `<@` two-byte trigger, not the bare `@`.
805    CustomOperatorVersusAtName,
806    /// `@@` is claimed by both [`OperatorSyntax::custom_operators`] (the general `@@`
807    /// operator — e.g. the prefix box-centre `@@ box` — when the `jsonb` family is off) and
808    /// [`SessionVariableSyntax::system_variables`] (MySQL `@@name`). The system-variable scan
809    /// arm precedes the operator arm, so `@@name` lexes as the variable whenever it is on,
810    /// silently shadowing the operator — a feature set enabling both must pick one meaning for
811    /// `@@`. (When the `jsonb` family is also on, the
812    /// [`JsonbSearchOperatorVersusSystemVariable`](Self::JsonbSearchOperatorVersusSystemVariable)
813    /// pair — checked first — owns the `@@` trigger instead. No shipped preset pairs these.)
814    CustomOperatorVersusSystemVariable,
815    /// `$` is claimed as a leading sigil by [`NumericLiteralSyntax::money_literals`],
816    /// [`ParameterSyntax::positional_dollar`], or
817    /// [`StringLiteralSyntax::dollar_quoted_strings`], yet a
818    /// [`byte_classes`](FeatureSet::byte_classes) table also marks `$` an
819    /// identifier-start byte. Two claimants for `$` is the same either/or as
820    /// [`HashCommentVersusHashIdentifier`](Self::HashCommentVersusHashIdentifier): keep `$` out of the identifier-start class
821    /// wherever a `$`-led feature is on.
822    DollarSigilVersusIdentifierByte,
823    /// `@` is claimed as a leading sigil by the `@`-family features
824    /// ([`ParameterSyntax::named_at`], [`SessionVariableSyntax::user_variables`], or
825    /// [`SessionVariableSyntax::system_variables`]), yet a
826    /// [`byte_classes`](FeatureSet::byte_classes) table also marks `@` an
827    /// identifier-start byte. Same either/or as [`HashCommentVersusHashIdentifier`](Self::HashCommentVersusHashIdentifier):
828    /// keep `@` out of the identifier-start class wherever an `@`-family feature is on.
829    AtSigilVersusIdentifierByte,
830    /// `:` is claimed as a leading sigil by [`ParameterSyntax::named_colon`], yet a
831    /// [`byte_classes`](FeatureSet::byte_classes) table also marks `:` an
832    /// identifier-start byte — so a lone `:` would begin an identifier instead of the
833    /// `:name` parameter or the slice separator. Same either/or as
834    /// [`HashCommentVersusHashIdentifier`](Self::HashCommentVersusHashIdentifier): keep `:` out of the identifier-start class
835    /// while `named_colon` is on.
836    ColonSigilVersusIdentifierByte,
837}
838
839/// A grammar-flag dependency left unsatisfied — a refinement feature enabled without the
840/// base feature it rides on — surfaced by [`FeatureSet::feature_dependencies`].
841///
842/// The grammar sibling of [`LexicalConflict`], and MECE-disjoint from it: every variant
843/// here is a pure grammar dependency (a flag inert without its base), never a shared
844/// tokenizer trigger. Unlike a lexical conflict, an unsatisfied dependency is not a
845/// soundness bug — the dependent flag is simply unreachable — so the registry is a
846/// test/debug-time property, not a runtime reject.
847///
848/// Each variant is named `<Dependent>Without<Base>` and its doc names both the dependent
849/// flag and the base flag it requires.
850///
851/// # Adding a dependent flag?
852///
853/// A flag that only *refines* a production another flag opens (a further clause, operand,
854/// guard, or bound reachable only once the base grammar is admitted) must be registered
855/// here before it lands — the "every preset is clean" claim is hand-maintained:
856/// 1. Identify the base flag whose grammar position the new flag extends (the field doc's
857///    "rides on" / "only reachable where" / "inert without" sentence names it).
858/// 2. Add a `<Dependent>Without<Base>` variant, a guard in
859///    [`feature_dependencies`](FeatureSet::feature_dependencies), and a per-variant
860///    detection test.
861/// 3. Point the field's prose sentence at the new variant so the doc and the registry stay
862///    in lock-step.
863///
864/// If the flag instead claims its *own* tokenizer trigger, that hazard is a
865/// [`LexicalConflict`], not a dependency — keep the two registries MECE.
866#[derive(Clone, Copy, Debug, PartialEq, Eq)]
867#[non_exhaustive]
868pub enum FeatureDependencyViolation {
869    /// [`QueryTailSyntax::key_lock_strengths`] (the `FOR NO KEY UPDATE` / `FOR KEY SHARE`
870    /// strengths) refines the strength keyword after `FOR`, so it requires
871    /// [`QueryTailSyntax::locking_clauses`]. Without the base gate the `FOR` clause is never
872    /// read, so the strength refinement is unreachable.
873    KeyLockStrengthsWithoutLockingClauses,
874    /// [`QueryTailSyntax::stacked_locking_clauses`] (multiple `FOR UPDATE`/`FOR SHARE` clauses
875    /// on one query) repeats the shared locking clause, so it requires
876    /// [`QueryTailSyntax::locking_clauses`]. Without the base gate no locking clause is read,
877    /// so there is nothing to stack.
878    StackedLockingClausesWithoutLockingClauses,
879    /// [`TableFactorSyntax::unnest_with_offset`] (the BigQuery `WITH OFFSET` tail) sits
880    /// on an `UNNEST` table factor, so it requires [`TableFactorSyntax::unnest`].
881    /// Without the base gate `UNNEST(` is left to the named-table path and the tail is
882    /// never reached.
883    UnnestWithOffsetWithoutUnnest,
884    /// [`ExpressionSyntax::slice_step`] (the third `base[lower:upper:step]` bound) is
885    /// reachable only once the bracket subscript has opened, so it requires
886    /// [`ExpressionSyntax::subscript`]. Without the base gate `[` is never read as a
887    /// subscript and the extra bound is unreachable.
888    SliceStepWithoutSubscript,
889    /// [`ExpressionSyntax::multidim_array_literals`] (the bare-bracket sub-row) is only a
890    /// value inside an `ARRAY[…]` constructor, so it requires
891    /// [`ExpressionSyntax::array_constructor`]. Without the base gate there is no
892    /// array-constructor element position for the sub-row to occupy.
893    MultidimArrayLiteralsWithoutArrayConstructor,
894    /// [`OperatorSyntax::quantified_comparison_lists`] (the scalar list/array operand of a
895    /// quantified comparison) rides the base quantifier reading, so it requires
896    /// [`OperatorSyntax::quantified_comparisons`]. Without the base gate `ANY`/`ALL`/`SOME`
897    /// is not read as a quantifier and the list operand is unreachable.
898    QuantifiedComparisonListsWithoutQuantifiedComparisons,
899    /// [`OperatorSyntax::quantified_arbitrary_operator`] (extending the quantifier past the
900    /// comparison operators) rides the base quantifier reading, so it requires
901    /// [`OperatorSyntax::quantified_comparisons`]. Without the base gate the quantifier is
902    /// unread and the arbitrary-operator extension is unreachable.
903    QuantifiedArbitraryOperatorWithoutQuantifiedComparisons,
904    /// [`OperatorSyntax::lambda_expressions`] (the DuckDB `x -> body` lambda) is a
905    /// grammar-position reading of the `->` lexeme, which is munched only under
906    /// [`OperatorSyntax::json_arrow_operators`], so it is inert without it. Without the base
907    /// gate no `->` token is produced and the lambda reading never fires.
908    LambdaExpressionsWithoutJsonArrowOperators,
909    /// [`MutationSyntax::cte_before_merge`] (a leading `WITH` before `MERGE`) is only
910    /// reachable where [`MutationSyntax::merge`] dispatches `MERGE`. Without the base gate
911    /// the `MERGE` after the CTE list is never dispatched.
912    CteBeforeMergeWithoutMerge,
913    /// [`MutationSyntax::merge_when_not_matched_by`] (the `WHEN NOT MATCHED BY
914    /// SOURCE | TARGET` arms) is only reachable where [`MutationSyntax::merge`] dispatches
915    /// `MERGE`. Without the base gate the `MERGE` statement is never parsed.
916    MergeWhenNotMatchedByWithoutMerge,
917    /// [`MutationSyntax::merge_insert_default_values`] (the `INSERT DEFAULT VALUES` merge
918    /// action) is only reachable where [`MutationSyntax::merge`] dispatches `MERGE`. Without
919    /// the base gate the `MERGE` statement is never parsed.
920    MergeInsertDefaultValuesWithoutMerge,
921    /// [`MutationSyntax::merge_insert_overriding`] (the `OVERRIDING {SYSTEM | USER} VALUE`
922    /// merge-insert override) is only reachable where [`MutationSyntax::merge`] dispatches
923    /// `MERGE`. Without the base gate the `MERGE` statement is never parsed.
924    MergeInsertOverridingWithoutMerge,
925    /// [`IndexAlterSyntax::alter_existence_guards`] (the `IF [NOT] EXISTS` guards inside
926    /// `ALTER TABLE`) is parsed only on the extended `ALTER TABLE` path, so it requires
927    /// [`IndexAlterSyntax::alter_table_extended`]. Without the base gate the non-extended
928    /// path parses no guard.
929    AlterExistenceGuardsWithoutAlterTableExtended,
930    /// [`IndexAlterSyntax::alter_column_set_data_type`] (the `ALTER COLUMN SET DATA TYPE`
931    /// / `SET`/`DROP NOT NULL` actions) is reached only on the extended `ALTER TABLE` path,
932    /// so it requires [`IndexAlterSyntax::alter_table_extended`]. Without the base gate the
933    /// `ALTER COLUMN` action is never reached.
934    AlterColumnSetDataTypeWithoutAlterTableExtended,
935    /// [`MaintenanceSyntax::checkpoint_database`] (the DuckDB `[FORCE] CHECKPOINT <database>`
936    /// operands) rides the base `CHECKPOINT` statement, so it requires
937    /// [`MaintenanceSyntax::checkpoint`]. Without the base gate `CHECKPOINT` is not dispatched
938    /// and the operands are unreachable.
939    CheckpointDatabaseWithoutCheckpoint,
940    /// [`MaintenanceSyntax::analyze_columns`] (the DuckDB `ANALYZE <table> (<cols>)` column
941    /// list) rides the base `ANALYZE` statement, so it requires
942    /// [`MaintenanceSyntax::analyze`]. Without the base gate `ANALYZE` is not dispatched and
943    /// the column list is unreachable.
944    AnalyzeColumnsWithoutAnalyze,
945    /// [`UtilitySyntax::load_bare_name`] (the DuckDB bare-identifier `LOAD <name>` argument)
946    /// rides the base `LOAD` statement, so it requires [`UtilitySyntax::load_extension`].
947    /// Without the base gate `LOAD` is not dispatched and the bare-name argument is
948    /// unreachable.
949    LoadBareNameWithoutLoadExtension,
950    /// [`UtilitySyntax::call_bare_name`] (MySQL's bare `CALL <name>` form, no parenthesized
951    /// argument list) rides the base `CALL` statement, so it requires
952    /// [`UtilitySyntax::call`]. Without the base gate `CALL` is not dispatched and the
953    /// bare-name form is unreachable.
954    CallBareNameWithoutCall,
955    /// [`UtilitySyntax::detach_if_exists`] (the `DETACH DATABASE IF EXISTS` guard) rides the
956    /// base `ATTACH`/`DETACH` statement, so it requires [`UtilitySyntax::attach`]. Without
957    /// the base gate `DETACH` is not dispatched and the guard is unreachable.
958    DetachIfExistsWithoutAttach,
959    /// [`UtilitySyntax::use_qualified_name`] (DuckDB's dotted `USE <catalog> . <schema>`
960    /// name, widening the accepted `USE` name arity from one to two parts) refines the name
961    /// grammar of the base `USE` statement, so it requires [`UtilitySyntax::use_statement`].
962    /// Without the base gate the leading `USE` is not dispatched, so the parser never reaches
963    /// the arity check the flag widens and the flag is inert.
964    UseQualifiedNameWithoutUseStatement,
965    /// [`AccessControlSyntax::access_control_extended_objects`] (the extended `GRANT`/`REVOKE`
966    /// object and prefix grammar) builds on the base `GRANT`/`REVOKE` statements, so it
967    /// requires [`AccessControlSyntax::access_control`]. Without the base gate the access-control
968    /// statements are not dispatched and the extended forms are unreachable.
969    AccessControlExtendedObjectsWithoutAccessControl,
970    /// [`AccessControlSyntax::access_control_account_grants`] (the MySQL account-based
971    /// `GRANT`/`REVOKE` grammar) is a route of the base `GRANT`/`REVOKE` statements, so it
972    /// requires [`AccessControlSyntax::access_control`]. Without the base gate the access-control
973    /// statements are not dispatched and the account-based grammar is unreachable.
974    AccountGrantsWithoutAccessControl,
975    /// [`UtilitySyntax::prepare_typed_parameters`] (the PostgreSQL `PREPARE
976    /// name(<type>, …)` parenthesized parameter-type list) widens the name position of the
977    /// base `PREPARE` grammar, so it requires [`UtilitySyntax::prepared_statements`].
978    /// Without the base gate `PREPARE` is not dispatched and the type list is unreachable.
979    PrepareTypedParametersWithoutPreparedStatements,
980}
981
982/// A grammar-position mutual exclusion — two features that both read the *same*
983/// parser-position head, which the parser resolves by a fixed branch order so enabling both
984/// silently shadows one reading — surfaced by [`FeatureSet::grammar_conflict`].
985///
986/// The third self-consistency registry, MECE-disjoint from its two siblings: unlike a
987/// [`LexicalConflict`] the contended surface is not a *tokenizer* trigger (each byte lexes to
988/// one fixed token; the contention is which *grammar* claims the token sequence), and unlike
989/// a [`FeatureDependencyViolation`] neither flag rides the other (both are independent
990/// grammar positions). Like a lexical conflict the severity is *unsoundness* — the shadowed
991/// reading is silently mis-parsed — but the shadow falls in the parser, not the tokenizer,
992/// so no lexical precedence governs it.
993///
994/// Each variant is named `<A>Versus<B>` and its doc names both features and the shared head
995/// they contend for.
996///
997/// # Two modelling species
998///
999/// The registered pairs fall into two shapes, both registrable under the same criteria:
1000///
1001/// - **Surface-overlap pairs** — two features that each *add* a reading at a shared head, so
1002///   enabling both leaves the head over-claimed. The `<ident> :` alias-vs-path pair and the two
1003///   `DO`-keyword readings ([`DoStatementVersusDoExpressionList`](Self::DoStatementVersusDoExpressionList))
1004///   are of this kind: each flag contributes one grammar to a position the other also reads.
1005/// - **Route flags** — a flag that selects *one of two mutually-exclusive whole grammars* for a
1006///   head rather than adding surface. When the route flag is on it dispatches its grammar
1007///   *before* the rival grammar is consulted, so the rival — even when explicitly enabled —
1008///   is silently deadened. [`AccountGrantsVersusExtendedObjects`](Self::AccountGrantsVersusExtendedObjects)
1009///   is the exemplar: [`access_control_account_grants`](AccessControlSyntax::access_control_account_grants)
1010///   routes `GRANT`/`REVOKE` to the MySQL account grammar, bypassing the
1011///   [`access_control_extended_objects`](AccessControlSyntax::access_control_extended_objects)
1012///   reading. A route flag is registrable **only when the grammar it displaces is itself a
1013///   feature flag** — that is what makes the both-on state independently expressible and its
1014///   resolution undefined-in-intent.
1015///
1016/// Not every route flag qualifies. MySQL's
1017/// [`variable_assignment`](SessionVariableSyntax::variable_assignment) is also a route flag — it
1018/// dispatches `SET` to the MySQL variable-assignment grammar before the standard `SET TIME ZONE`
1019/// / `SET SESSION AUTHORIZATION` forms are read — but the grammar it displaces is *unconditional
1020/// base grammar with no rival flag*. There is therefore no second flag to express "I also want
1021/// the standard `SET` forms", so no both-on contradiction exists, and MySQL (a shipped preset)
1022/// already exercises the route deterministically. Per the registrability criterion below, a
1023/// deterministic resolution a shipped preset relies on is a conflict-*free* union, not a mutual
1024/// exclusion (the same reasoning that leaves the `DESCRIBE`/`SUMMARIZE` leader unregistered), so
1025/// `variable_assignment` gets **no variant**. Registering it would require either promoting the
1026/// standard `SET` config grammar to its own flag or converting `variable_assignment` to an enum
1027/// axis (the `PipeOperator`/`DoubleAmpersand` either-by-type precedent) — an architectural
1028/// modelling change, not a registry addition.
1029///
1030/// # Registry coverage
1031///
1032/// The four entries cover the `<ident> :` lexical pair and three parser-position mutual
1033/// exclusions — the `DO`, prepared-statement, and `GRANT` heads. The grammar-level gates are
1034/// therefore *not* all pairwise-independent: a preset that unions two of these contenders
1035/// needs a registered resolution, which is why this registry exists rather than relying on
1036/// the gates being independent.
1037///
1038/// # Adding a grammar-position feature?
1039///
1040/// A feature whose grammar reads a token sequence at a position another feature's grammar
1041/// also reads must be audited against this registry before it lands — the "every preset is
1042/// clean" claim is hand-maintained:
1043/// 1. Identify the parser-position head the new grammar dispatches on (the field doc's "reads
1044///    the same … head" / "must not be enabled together" sentence names the rival).
1045/// 2. Confirm the contention is *undefined* — the two branches share no lookahead or fixed
1046///    dispatch order that keeps both readable — and that *no shipped preset enables both*. A
1047///    pair a preset unions with a documented deterministic resolution (the
1048///    `DESCRIBE`/`SUMMARIZE` dispatch, the `GROUP BY ALL` lookahead split) is a
1049///    conflict-*free* union, not a mutual exclusion, and gets no variant.
1050/// 3. Add an `<A>Versus<B>` variant, a guard in
1051///    [`grammar_conflict`](FeatureSet::grammar_conflict), and a per-variant detection test.
1052/// 4. Point each field's prose sentence at the new variant so the docs and the registry stay
1053///    in lock-step.
1054///
1055/// If the contended surface is instead a shared *tokenizer* trigger, that hazard is a
1056/// [`LexicalConflict`]; if one flag merely rides the other's base grammar, it is a
1057/// [`FeatureDependencyViolation`] — keep the three registries MECE.
1058#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1059#[non_exhaustive]
1060pub enum GrammarConflict {
1061    /// The `<ident> :` head is claimed by both [`SelectSyntax::prefix_colon_alias`] (DuckDB's
1062    /// alias-before-value `SELECT j : 42` / `FROM b : a`) and
1063    /// [`ExpressionSyntax::semi_structured_access`] (the `base : key` postfix path). The `:`
1064    /// always lexes as a lone `Colon` punctuation token — no [`LexicalConflict`] governs it —
1065    /// so the contention is purely grammatical: at a value / select-item head the prefix-alias
1066    /// branch is tried first, binding `a : b` as an alias and silently shadowing the path
1067    /// reading, so a feature set must pick one meaning for a leading `<ident> :`. No shipped
1068    /// preset pairs them (DuckDB/Lenient enable the prefix alias with
1069    /// [`semi_structured_access`](ExpressionSyntax::semi_structured_access) off;
1070    /// Snowflake/Databricks enable the path with
1071    /// [`prefix_colon_alias`](SelectSyntax::prefix_colon_alias) off).
1072    PrefixColonAliasVersusSemiStructuredAccess,
1073    /// The leading `DO` head is claimed by both [`UtilitySyntax::do_statement`] (PostgreSQL's
1074    /// `DO [LANGUAGE <lang>] '<body>'` anonymous code block) and
1075    /// [`UtilitySyntax::do_expression_list`] (MySQL's `DO <expr> [, <expr> …]`
1076    /// evaluate-and-discard statement). The `DO` byte lexes to one contextual keyword — no
1077    /// [`LexicalConflict`] governs it — so the contention is purely grammatical: the code-block
1078    /// branch is tried first, so under both-on `DO 'x'` (MySQL intent) mis-parses as a PostgreSQL
1079    /// block body and `DO 1, 2` over-rejects, the reading fixed only by dispatch order. No shipped
1080    /// preset pairs them (PostgreSQL and Lenient arm [`do_statement`](UtilitySyntax::do_statement)
1081    /// with [`do_expression_list`](UtilitySyntax::do_expression_list) off; MySQL the reverse).
1082    DoStatementVersusDoExpressionList,
1083    /// The leading `PREPARE`/`EXECUTE`/`DEALLOCATE` head is claimed by both
1084    /// [`UtilitySyntax::prepared_statements`] (DuckDB's typed-`AS` prepared-statement lifecycle)
1085    /// and [`UtilitySyntax::prepared_statements_from`] (MySQL's `FROM`/`USING` lifecycle) —
1086    /// different grammars on the same three leading keywords, each byte lexing to one contextual
1087    /// keyword (no [`LexicalConflict`]). The statement dispatch resolves the `PREPARE`/`EXECUTE`
1088    /// heads DuckDB-first by branch order, but the `DEALLOCATE` tail resolves MySQL-first (the
1089    /// `PREPARE` keyword becomes mandatory whenever `prepared_statements_from` is on), so the
1090    /// combination is *incoherent across one lifecycle*: `DEALLOCATE p` (valid under
1091    /// `prepared_statements` alone) errors while `PREPARE p AS …` keeps the DuckDB reading. No
1092    /// shipped preset pairs them (DuckDB/PostgreSQL/Lenient arm the typed-`AS` form; MySQL the
1093    /// `FROM`/`USING` form). Because the combination is registry-rejected, the three keyword sites
1094    /// (`query.rs` dispatch, `util.rs` `finish_deallocate_statement`) leave the both-on semantics
1095    /// deliberately undefined rather than reconciling the two winners.
1096    PreparedStatementsVersusPreparedStatementsFrom,
1097    /// The `GRANT`/`REVOKE` head is claimed by both
1098    /// [`AccessControlSyntax::access_control_account_grants`] (MySQL's account-based grammar) and
1099    /// [`AccessControlSyntax::access_control_extended_objects`] (the standard/PostgreSQL extended
1100    /// object and prefix grammar). A **route-flag** conflict (see the enum doc): the account route
1101    /// dispatches its whole grammar before the extended-object reading is consulted, so enabling
1102    /// both silently deadens the extended-object grammar even when it is explicitly on. The `GRANT`
1103    /// byte lexes to one keyword (no [`LexicalConflict`]) and neither flag rides the other, so the
1104    /// contention is purely grammatical, resolved by fixed branch order with no lookahead. No
1105    /// shipped preset pairs them: MySQL arms the account route with extended objects off; ANSI/
1106    /// PostgreSQL/DuckDB/Lenient keep the extended-object grammar with the account route off — the
1107    /// asymmetry the [`access_control_account_grants`](AccessControlSyntax::access_control_account_grants)
1108    /// field doc's "a dialect cannot enable both grant grammars at once" sentence records.
1109    AccountGrantsVersusExtendedObjects,
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use crate::dialect::lex_class::CLASS_IDENTIFIER_START;
1115    use crate::dialect::*;
1116
1117    #[test]
1118    fn lexical_conflict_flags_a_sigil_byte_marked_identifier_start() {
1119        // The `#`-comment/`#`-identifier either/or, generalized: a feature that leads
1120        // with a sigil byte must not also have that byte in the identifier-start class of
1121        // a custom `ByteClasses`, or an identifier scan would shadow the sigil dispatch.
1122        // No shipped preset trips these — `STANDARD_BYTE_CLASSES` marks none of `$`/`@`/`:`
1123        // identifier-start — so each is reached only through a custom table.
1124        let dollar = FeatureSet::ANSI.with(
1125            FeatureDelta::EMPTY
1126                .parameters(ParameterSyntax {
1127                    positional_dollar: true,
1128                    ..ParameterSyntax::ANSI
1129                })
1130                .byte_classes(
1131                    FeatureSet::ANSI
1132                        .byte_classes
1133                        .with_class(b'$', CLASS_IDENTIFIER_START),
1134                ),
1135        );
1136        assert_eq!(
1137            dollar.lexical_conflict(),
1138            Some(LexicalConflict::DollarSigilVersusIdentifierByte),
1139        );
1140
1141        let at = FeatureSet::ANSI.with(
1142            FeatureDelta::EMPTY
1143                .parameters(ParameterSyntax {
1144                    named_at: true,
1145                    ..ParameterSyntax::ANSI
1146                })
1147                .byte_classes(
1148                    FeatureSet::ANSI
1149                        .byte_classes
1150                        .with_class(b'@', CLASS_IDENTIFIER_START),
1151                ),
1152        );
1153        assert_eq!(
1154            at.lexical_conflict(),
1155            Some(LexicalConflict::AtSigilVersusIdentifierByte),
1156        );
1157
1158        let colon = FeatureSet::ANSI.with(
1159            FeatureDelta::EMPTY
1160                .parameters(ParameterSyntax {
1161                    named_colon: true,
1162                    ..ParameterSyntax::ANSI
1163                })
1164                .byte_classes(
1165                    FeatureSet::ANSI
1166                        .byte_classes
1167                        .with_class(b':', CLASS_IDENTIFIER_START),
1168                ),
1169        );
1170        assert_eq!(
1171            colon.lexical_conflict(),
1172            Some(LexicalConflict::ColonSigilVersusIdentifierByte),
1173        );
1174
1175        // The check is the pair, not the byte class alone: marking `$` identifier-start
1176        // with no `$`-led feature on is a coherent custom choice, not a conflict.
1177        let dollar_identifier_only = FeatureSet::ANSI.with(
1178            FeatureDelta::EMPTY.byte_classes(
1179                FeatureSet::ANSI
1180                    .byte_classes
1181                    .with_class(b'$', CLASS_IDENTIFIER_START),
1182            ),
1183        );
1184        assert_eq!(dollar_identifier_only.lexical_conflict(), None);
1185    }
1186
1187    #[test]
1188    fn positional_column_conflicts_on_the_hash_trigger() {
1189        // DuckDB's `#n` positional reference claims `#`+digit; pairing it with either
1190        // other `#` claimant is a conflict, since the tokenizer resolves `#` to one
1191        // meaning and shadows the rest. No shipped preset pairs them (DuckDB has neither
1192        // rival), so each is reached only through a constructed combination.
1193        let with_xor = FeatureSet::DUCKDB.with(FeatureDelta::EMPTY.hash_bitwise_xor(true));
1194        assert_eq!(
1195            with_xor.lexical_conflict(),
1196            Some(LexicalConflict::HashXorOperatorVersusPositionalColumn),
1197        );
1198
1199        let with_comment =
1200            FeatureSet::DUCKDB.with(FeatureDelta::EMPTY.comment_syntax(CommentSyntax {
1201                line_comment_hash: true,
1202                ..FeatureSet::DUCKDB.comment_syntax
1203            }));
1204        assert_eq!(
1205            with_comment.lexical_conflict(),
1206            Some(LexicalConflict::HashCommentVersusPositionalColumn),
1207        );
1208
1209        // DuckDB itself — positional on (proven by the parser round-trip tests), no rival
1210        // `#` reading — is consistent.
1211        assert_eq!(FeatureSet::DUCKDB.lexical_conflict(), None);
1212    }
1213
1214    #[test]
1215    fn every_shipped_preset_satisfies_its_feature_dependencies() {
1216        // The dependency sibling of the per-preset lexical assertions: every dependent
1217        // grammar flag a shipped preset sets also has the base flag it rides on, so no
1218        // preset ships an inert flag.
1219        for preset in [
1220            FeatureSet::ANSI,
1221            FeatureSet::POSTGRES,
1222            FeatureSet::MYSQL,
1223            FeatureSet::SQLITE,
1224            FeatureSet::DUCKDB,
1225            FeatureSet::BIGQUERY,
1226            FeatureSet::HIVE,
1227            FeatureSet::CLICKHOUSE,
1228            FeatureSet::DATABRICKS,
1229            FeatureSet::MSSQL,
1230            FeatureSet::SNOWFLAKE,
1231            FeatureSet::REDSHIFT,
1232            FeatureSet::LENIENT,
1233        ] {
1234            assert_eq!(preset.feature_dependencies(), None);
1235            assert!(preset.has_satisfied_feature_dependencies());
1236        }
1237    }
1238
1239    #[test]
1240    fn feature_dependencies_detects_each_unsatisfied_dependency() {
1241        // ANSI is dependency-clean, so flipping exactly one base off while its dependent
1242        // stays on isolates that variant as the first (and only) violation. Sibling
1243        // dependents on the same multi-dependent base (`locking_clauses`,
1244        // `quantified_comparisons`, `merge`, `alter_table_extended`) are pinned off so the
1245        // intended variant is the one returned.
1246        use FeatureDependencyViolation as V;
1247
1248        let key_lock =
1249            FeatureSet::ANSI.with(FeatureDelta::EMPTY.query_tail_syntax(QueryTailSyntax {
1250                locking_clauses: false,
1251                key_lock_strengths: true,
1252                stacked_locking_clauses: false,
1253                ..FeatureSet::ANSI.query_tail_syntax
1254            }));
1255        assert_eq!(
1256            key_lock.feature_dependencies(),
1257            Some(V::KeyLockStrengthsWithoutLockingClauses),
1258        );
1259
1260        let stacked =
1261            FeatureSet::ANSI.with(FeatureDelta::EMPTY.query_tail_syntax(QueryTailSyntax {
1262                locking_clauses: false,
1263                key_lock_strengths: false,
1264                stacked_locking_clauses: true,
1265                ..FeatureSet::ANSI.query_tail_syntax
1266            }));
1267        assert_eq!(
1268            stacked.feature_dependencies(),
1269            Some(V::StackedLockingClausesWithoutLockingClauses),
1270        );
1271
1272        let unnest_offset =
1273            FeatureSet::ANSI.with(FeatureDelta::EMPTY.table_factor_syntax(TableFactorSyntax {
1274                unnest: false,
1275                unnest_with_offset: true,
1276                ..FeatureSet::ANSI.table_factor_syntax
1277            }));
1278        assert_eq!(
1279            unnest_offset.feature_dependencies(),
1280            Some(V::UnnestWithOffsetWithoutUnnest),
1281        );
1282
1283        let slice =
1284            FeatureSet::ANSI.with(FeatureDelta::EMPTY.expression_syntax(ExpressionSyntax {
1285                subscript: false,
1286                slice_step: true,
1287                ..FeatureSet::ANSI.expression_syntax
1288            }));
1289        assert_eq!(
1290            slice.feature_dependencies(),
1291            Some(V::SliceStepWithoutSubscript)
1292        );
1293
1294        let multidim =
1295            FeatureSet::ANSI.with(FeatureDelta::EMPTY.expression_syntax(ExpressionSyntax {
1296                array_constructor: false,
1297                multidim_array_literals: true,
1298                ..FeatureSet::ANSI.expression_syntax
1299            }));
1300        assert_eq!(
1301            multidim.feature_dependencies(),
1302            Some(V::MultidimArrayLiteralsWithoutArrayConstructor),
1303        );
1304
1305        let quant_lists =
1306            FeatureSet::ANSI.with(FeatureDelta::EMPTY.operator_syntax(OperatorSyntax {
1307                quantified_comparisons: false,
1308                quantified_comparison_lists: true,
1309                quantified_arbitrary_operator: false,
1310                ..FeatureSet::ANSI.operator_syntax
1311            }));
1312        assert_eq!(
1313            quant_lists.feature_dependencies(),
1314            Some(V::QuantifiedComparisonListsWithoutQuantifiedComparisons),
1315        );
1316
1317        let quant_arbitrary =
1318            FeatureSet::ANSI.with(FeatureDelta::EMPTY.operator_syntax(OperatorSyntax {
1319                quantified_comparisons: false,
1320                quantified_comparison_lists: false,
1321                quantified_arbitrary_operator: true,
1322                ..FeatureSet::ANSI.operator_syntax
1323            }));
1324        assert_eq!(
1325            quant_arbitrary.feature_dependencies(),
1326            Some(V::QuantifiedArbitraryOperatorWithoutQuantifiedComparisons),
1327        );
1328
1329        let lambda = FeatureSet::ANSI.with(FeatureDelta::EMPTY.operator_syntax(OperatorSyntax {
1330            json_arrow_operators: false,
1331            lambda_expressions: true,
1332            ..FeatureSet::ANSI.operator_syntax
1333        }));
1334        assert_eq!(
1335            lambda.feature_dependencies(),
1336            Some(V::LambdaExpressionsWithoutJsonArrowOperators),
1337        );
1338
1339        // The four `MERGE` extensions, each isolated with `merge` off and its three
1340        // siblings pinned off.
1341        let merge_base = MutationSyntax {
1342            merge: false,
1343            cte_before_merge: false,
1344            merge_when_not_matched_by: false,
1345            merge_insert_default_values: false,
1346            merge_insert_overriding: false,
1347            merge_update_set_star: false,
1348            merge_insert_star_by_name: false,
1349            merge_error_action: false,
1350            ..FeatureSet::ANSI.mutation_syntax
1351        };
1352        let cte_merge =
1353            FeatureSet::ANSI.with(FeatureDelta::EMPTY.mutation_syntax(MutationSyntax {
1354                cte_before_merge: true,
1355                ..merge_base
1356            }));
1357        assert_eq!(
1358            cte_merge.feature_dependencies(),
1359            Some(V::CteBeforeMergeWithoutMerge),
1360        );
1361        let when_not_matched =
1362            FeatureSet::ANSI.with(FeatureDelta::EMPTY.mutation_syntax(MutationSyntax {
1363                merge_when_not_matched_by: true,
1364                ..merge_base
1365            }));
1366        assert_eq!(
1367            when_not_matched.feature_dependencies(),
1368            Some(V::MergeWhenNotMatchedByWithoutMerge),
1369        );
1370        let insert_defaults =
1371            FeatureSet::ANSI.with(FeatureDelta::EMPTY.mutation_syntax(MutationSyntax {
1372                merge_insert_default_values: true,
1373                ..merge_base
1374            }));
1375        assert_eq!(
1376            insert_defaults.feature_dependencies(),
1377            Some(V::MergeInsertDefaultValuesWithoutMerge),
1378        );
1379        let insert_overriding =
1380            FeatureSet::ANSI.with(FeatureDelta::EMPTY.mutation_syntax(MutationSyntax {
1381                merge_insert_overriding: true,
1382                merge_update_set_star: false,
1383                merge_insert_star_by_name: false,
1384                merge_error_action: false,
1385                ..merge_base
1386            }));
1387        assert_eq!(
1388            insert_overriding.feature_dependencies(),
1389            Some(V::MergeInsertOverridingWithoutMerge),
1390        );
1391
1392        let alter_guards =
1393            FeatureSet::ANSI.with(FeatureDelta::EMPTY.index_alter_syntax(IndexAlterSyntax {
1394                alter_table_extended: false,
1395                alter_existence_guards: true,
1396                alter_column_set_data_type: false,
1397                ..FeatureSet::ANSI.index_alter_syntax
1398            }));
1399        assert_eq!(
1400            alter_guards.feature_dependencies(),
1401            Some(V::AlterExistenceGuardsWithoutAlterTableExtended),
1402        );
1403        let alter_set_type =
1404            FeatureSet::ANSI.with(FeatureDelta::EMPTY.index_alter_syntax(IndexAlterSyntax {
1405                alter_table_extended: false,
1406                alter_existence_guards: false,
1407                alter_column_set_data_type: true,
1408                ..FeatureSet::ANSI.index_alter_syntax
1409            }));
1410        assert_eq!(
1411            alter_set_type.feature_dependencies(),
1412            Some(V::AlterColumnSetDataTypeWithoutAlterTableExtended),
1413        );
1414
1415        let checkpoint_db =
1416            FeatureSet::ANSI.with(FeatureDelta::EMPTY.maintenance_syntax(MaintenanceSyntax {
1417                checkpoint: false,
1418                checkpoint_database: true,
1419                ..FeatureSet::ANSI.maintenance_syntax
1420            }));
1421        assert_eq!(
1422            checkpoint_db.feature_dependencies(),
1423            Some(V::CheckpointDatabaseWithoutCheckpoint),
1424        );
1425        let analyze_cols =
1426            FeatureSet::ANSI.with(FeatureDelta::EMPTY.maintenance_syntax(MaintenanceSyntax {
1427                analyze: false,
1428                analyze_columns: true,
1429                ..FeatureSet::ANSI.maintenance_syntax
1430            }));
1431        assert_eq!(
1432            analyze_cols.feature_dependencies(),
1433            Some(V::AnalyzeColumnsWithoutAnalyze),
1434        );
1435        let load_bare = FeatureSet::ANSI.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1436            load_extension: false,
1437            load_bare_name: true,
1438            ..FeatureSet::ANSI.utility_syntax
1439        }));
1440        assert_eq!(
1441            load_bare.feature_dependencies(),
1442            Some(V::LoadBareNameWithoutLoadExtension),
1443        );
1444        let call_bare = FeatureSet::ANSI.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1445            call: false,
1446            call_bare_name: true,
1447            ..FeatureSet::ANSI.utility_syntax
1448        }));
1449        assert_eq!(
1450            call_bare.feature_dependencies(),
1451            Some(V::CallBareNameWithoutCall),
1452        );
1453        let detach = FeatureSet::ANSI.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1454            attach: false,
1455            detach_if_exists: true,
1456            ..FeatureSet::ANSI.utility_syntax
1457        }));
1458        assert_eq!(
1459            detach.feature_dependencies(),
1460            Some(V::DetachIfExistsWithoutAttach),
1461        );
1462        let use_qualified =
1463            FeatureSet::ANSI.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1464                use_statement: false,
1465                use_qualified_name: true,
1466                ..FeatureSet::ANSI.utility_syntax
1467            }));
1468        assert_eq!(
1469            use_qualified.feature_dependencies(),
1470            Some(V::UseQualifiedNameWithoutUseStatement),
1471        );
1472        let extended_ac = FeatureSet::ANSI.with(FeatureDelta::EMPTY.access_control_syntax(
1473            AccessControlSyntax {
1474                access_control: false,
1475                access_control_extended_objects: true,
1476                user_role_management: false,
1477                access_control_account_grants: false,
1478            },
1479        ));
1480        assert_eq!(
1481            extended_ac.feature_dependencies(),
1482            Some(V::AccessControlExtendedObjectsWithoutAccessControl),
1483        );
1484        let account_grants = FeatureSet::ANSI.with(FeatureDelta::EMPTY.access_control_syntax(
1485            AccessControlSyntax {
1486                access_control: false,
1487                access_control_extended_objects: false,
1488                user_role_management: false,
1489                access_control_account_grants: true,
1490            },
1491        ));
1492        assert_eq!(
1493            account_grants.feature_dependencies(),
1494            Some(V::AccountGrantsWithoutAccessControl),
1495        );
1496        let typed_params =
1497            FeatureSet::ANSI.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1498                prepared_statements: false,
1499                prepare_typed_parameters: true,
1500                ..FeatureSet::ANSI.utility_syntax
1501            }));
1502        assert_eq!(
1503            typed_params.feature_dependencies(),
1504            Some(V::PrepareTypedParametersWithoutPreparedStatements),
1505        );
1506    }
1507
1508    #[test]
1509    fn feature_dependencies_and_lexical_conflict_stay_mece() {
1510        // A set can carry a lexical conflict and be dependency-clean, or vice versa —
1511        // the two registries never both claim the same combination. ANSI plus a bracket
1512        // slice with no subscript is a pure dependency violation and no lexical conflict.
1513        let dep_only =
1514            FeatureSet::ANSI.with(FeatureDelta::EMPTY.expression_syntax(ExpressionSyntax {
1515                subscript: false,
1516                slice_step: true,
1517                ..FeatureSet::ANSI.expression_syntax
1518            }));
1519        assert_eq!(
1520            dep_only.feature_dependencies(),
1521            Some(FeatureDependencyViolation::SliceStepWithoutSubscript),
1522        );
1523        assert_eq!(dep_only.lexical_conflict(), None);
1524    }
1525
1526    #[test]
1527    fn without_dangling_dependents_clears_only_the_inert_refinements() {
1528        // A base flag turned off under a preset that had refinements riding it leaves those
1529        // refinements dangling; the normal form clears exactly them, satisfies the
1530        // dependency registry, and touches nothing else (the base flag it *did* keep, the
1531        // rest of the set). PostgreSQL enables `merge` plus its `cte_before_merge` /
1532        // `merge_when_not_matched_by` refinements — dropping `merge` dangles both.
1533        let dangling =
1534            FeatureSet::POSTGRES.with(FeatureDelta::EMPTY.mutation_syntax(MutationSyntax {
1535                merge: false,
1536                ..FeatureSet::POSTGRES.mutation_syntax
1537            }));
1538        assert!(dangling.feature_dependencies().is_some());
1539
1540        let cleaned = dangling.without_dangling_dependents();
1541        assert_eq!(cleaned.feature_dependencies(), None);
1542        assert!(cleaned.has_satisfied_feature_dependencies());
1543        // The inert refinements were cleared…
1544        assert!(!cleaned.mutation_syntax.cte_before_merge);
1545        assert!(!cleaned.mutation_syntax.merge_when_not_matched_by);
1546        // …and nothing that was already consistent changed: the (still-off) base is
1547        // untouched, and an already-clean set is returned verbatim.
1548        assert_eq!(
1549            cleaned.mutation_syntax.merge,
1550            dangling.mutation_syntax.merge
1551        );
1552        assert_eq!(
1553            FeatureSet::POSTGRES.without_dangling_dependents(),
1554            FeatureSet::POSTGRES,
1555        );
1556    }
1557
1558    #[test]
1559    fn every_shipped_preset_has_no_grammar_conflict() {
1560        // The parser-position sibling of the per-preset lexical and dependency assertions:
1561        // no shipped preset enables two features that contend for the same grammar head, so this
1562        // one loop covers every registered variant collectively (any preset tripping a new guard
1563        // would return `Some` here). The four registered pairs are each split across presets:
1564        // `prefix_colon_alias` vs `semi_structured_access` (DuckDB/Lenient have the prefix alias,
1565        // Snowflake/Databricks the path); `do_statement` vs `do_expression_list` (PostgreSQL/
1566        // Lenient the block, MySQL the expression list); `prepared_statements` vs
1567        // `prepared_statements_from` (DuckDB/PostgreSQL/Lenient the typed-`AS` form, MySQL the
1568        // `FROM`/`USING` form); and `access_control_account_grants` vs
1569        // `access_control_extended_objects` (MySQL the account route, everyone else the extended
1570        // objects). Lenient — the union preset that pairs `describe` with `describe_summarize` —
1571        // stays clean because that shared `DESCRIBE` leader is a deterministic dispatch-order
1572        // union, not a registered conflict.
1573        for preset in [
1574            FeatureSet::ANSI,
1575            FeatureSet::POSTGRES,
1576            FeatureSet::MYSQL,
1577            FeatureSet::SQLITE,
1578            FeatureSet::DUCKDB,
1579            FeatureSet::BIGQUERY,
1580            FeatureSet::HIVE,
1581            FeatureSet::CLICKHOUSE,
1582            FeatureSet::DATABRICKS,
1583            FeatureSet::MSSQL,
1584            FeatureSet::SNOWFLAKE,
1585            FeatureSet::REDSHIFT,
1586            FeatureSet::LENIENT,
1587        ] {
1588            assert_eq!(preset.grammar_conflict(), None);
1589            assert!(preset.has_no_grammar_conflict());
1590        }
1591    }
1592
1593    #[test]
1594    fn grammar_conflict_detects_prefix_colon_versus_semi_structured() {
1595        // DuckDB ships the prefix colon alias with `semi_structured_access` off; forcing the
1596        // path grammar on alongside it pairs the two `<ident> :` head claimants, which the
1597        // registry must flag. (`named_colon` stays off, so this is a pure grammar contention
1598        // and not the sibling `ColonParameterVersusSliceBound` lexical conflict.)
1599        let both =
1600            FeatureSet::DUCKDB.with(FeatureDelta::EMPTY.expression_syntax(ExpressionSyntax {
1601                semi_structured_access: true,
1602                ..FeatureSet::DUCKDB.expression_syntax
1603            }));
1604        assert_eq!(
1605            both.grammar_conflict(),
1606            Some(GrammarConflict::PrefixColonAliasVersusSemiStructuredAccess),
1607        );
1608        assert!(!both.has_no_grammar_conflict());
1609        // Neither flag alone is a conflict.
1610        assert_eq!(FeatureSet::DUCKDB.grammar_conflict(), None);
1611        assert_eq!(FeatureSet::SNOWFLAKE.grammar_conflict(), None);
1612    }
1613
1614    #[test]
1615    fn grammar_conflict_stays_mece_with_the_lexical_and_dependency_siblings() {
1616        // A pure grammar-position contention carries no tokenizer trigger and no base-flag
1617        // dependency: DuckDB plus `semi_structured_access` is a grammar conflict yet stays
1618        // lexically consistent (`named_colon` is off, so `:` has one claimant) and
1619        // dependency-clean.
1620        let grammar_only =
1621            FeatureSet::DUCKDB.with(FeatureDelta::EMPTY.expression_syntax(ExpressionSyntax {
1622                semi_structured_access: true,
1623                ..FeatureSet::DUCKDB.expression_syntax
1624            }));
1625        assert_eq!(
1626            grammar_only.grammar_conflict(),
1627            Some(GrammarConflict::PrefixColonAliasVersusSemiStructuredAccess),
1628        );
1629        assert_eq!(grammar_only.lexical_conflict(), None);
1630        assert_eq!(grammar_only.feature_dependencies(), None);
1631    }
1632
1633    #[test]
1634    fn grammar_conflict_detects_do_statement_versus_do_expression_list() {
1635        // MySQL ships the `DO <expr-list>` statement (`do_expression_list` on) with the PostgreSQL
1636        // code block off; forcing `do_statement` on alongside it pairs the two `DO`-head readings.
1637        let both = FeatureSet::MYSQL.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1638            do_statement: true,
1639            ..FeatureSet::MYSQL.utility_syntax
1640        }));
1641        assert_eq!(
1642            both.grammar_conflict(),
1643            Some(GrammarConflict::DoStatementVersusDoExpressionList),
1644        );
1645        assert!(!both.has_no_grammar_conflict());
1646        // Neither dialect alone is a conflict.
1647        assert_eq!(FeatureSet::MYSQL.grammar_conflict(), None);
1648        assert_eq!(FeatureSet::POSTGRES.grammar_conflict(), None);
1649    }
1650
1651    #[test]
1652    fn grammar_conflict_detects_prepared_statements_versus_prepared_statements_from() {
1653        // DuckDB ships the typed-`AS` prepared-statement lifecycle (`prepared_statements` on) with
1654        // MySQL's `FROM`/`USING` form off; forcing `prepared_statements_from` on alongside it pairs
1655        // the two lifecycles on the shared `PREPARE`/`EXECUTE`/`DEALLOCATE` keywords — the
1656        // combination whose `DEALLOCATE` tail is incoherent with the dispatch order.
1657        let both = FeatureSet::DUCKDB.with(FeatureDelta::EMPTY.utility_syntax(UtilitySyntax {
1658            prepared_statements_from: true,
1659            ..FeatureSet::DUCKDB.utility_syntax
1660        }));
1661        assert_eq!(
1662            both.grammar_conflict(),
1663            Some(GrammarConflict::PreparedStatementsVersusPreparedStatementsFrom),
1664        );
1665        assert!(!both.has_no_grammar_conflict());
1666        // Neither dialect alone is a conflict.
1667        assert_eq!(FeatureSet::DUCKDB.grammar_conflict(), None);
1668        assert_eq!(FeatureSet::MYSQL.grammar_conflict(), None);
1669    }
1670
1671    #[test]
1672    fn grammar_conflict_detects_account_grants_versus_extended_objects() {
1673        // MySQL ships the account-based grant route (`access_control_account_grants` on) with the
1674        // extended-object grammar off; forcing `access_control_extended_objects` on alongside it
1675        // pairs the two `GRANT`/`REVOKE` grammars — the route-flag conflict, where the account
1676        // route deadens the explicitly-enabled extended-object reading. (`access_control` stays on,
1677        // so the extended-object flag is not a dependency violation.)
1678        let both = FeatureSet::MYSQL.with(FeatureDelta::EMPTY.access_control_syntax(
1679            AccessControlSyntax {
1680                access_control_extended_objects: true,
1681                ..FeatureSet::MYSQL.access_control_syntax
1682            },
1683        ));
1684        assert_eq!(
1685            both.grammar_conflict(),
1686            Some(GrammarConflict::AccountGrantsVersusExtendedObjects),
1687        );
1688        assert!(!both.has_no_grammar_conflict());
1689        assert_eq!(both.feature_dependencies(), None);
1690        // Neither route alone is a conflict.
1691        assert_eq!(FeatureSet::MYSQL.grammar_conflict(), None);
1692        assert_eq!(FeatureSet::POSTGRES.grammar_conflict(), None);
1693    }
1694}