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}