Skip to main content

razeri_plugin_api/
conformance.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **Every obligation a dialect owes, reported at once.**
6//!
7//! Two people built dialects against Razeri's authoring guide without help.
8//! Between them they met twenty-seven obligations one `cargo test` at a time —
9//! a roster fails, you add a row, the next roster fails. Not one of those
10//! failures was discoverable before it fired. This module is the report that
11//! answers all of them in one pass.
12//!
13//! # Running it
14//!
15//! From your own crate's tests, where your dialect is linked and Razeri's
16//! registry can see it:
17//!
18//! ```no_run
19//! # use razeri_plugin_api::conformance;
20//! # fn example(my_dialect: &dyn razeri_plugin_api::PlatformDialect) {
21//! let report = conformance::check(my_dialect);
22//! assert!(report.is_clean(), "{report}");
23//! # }
24//! ```
25//!
26//! # What it can and cannot see
27//!
28//! Everything here is read off the dialect object, so it works wherever your
29//! plugin compiles: it grades what your dialect declares and what its
30//! renderers return. It runs no SQL, so anything only an engine can settle —
31//! whether the SQL your dialect emits is the SQL that engine accepts — stays
32//! yours to test against a real instance.
33//!
34//! # Your dialect's name
35//!
36//! Your platform's names are yours. `DialectInfo::name` is the one Razeri
37//! reports, `aliases` is every other spelling a design may use for it, and the
38//! resolver reads both off the dialects that are loaded — so the report asks
39//! only that your own spellings reach you, and there is no table inside Razeri
40//! for your platform to be missing from. The one thing it will tell you is that
41//! a spelling you declared is one another loaded platform also answers to,
42//! because then the spelling names neither of you.
43
44use crate::PlatformDialect;
45use razeri_plugin_contract::{
46    ColumnEvolution, ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities,
47    DialectFamily, EntityRef, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
48    RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
49    TableFunctionForm, UNKNOWN_PLATFORM,
50    data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
51    storage_strategy::{
52        CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
53    },
54};
55use std::fmt;
56
57/// How much a finding matters.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Severity {
60    /// **This dialect will produce SQL its engine rejects.** Fix it before
61    /// anything else.
62    Blocking,
63    /// **A question this dialect has not answered.** Razeri has a default and
64    /// will use it; the default was written for a different engine.
65    Unanswered,
66    /// Worth a look. Nothing is broken.
67    Note,
68}
69
70impl fmt::Display for Severity {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(match self {
73            Self::Blocking => "must fix",
74            Self::Unanswered => "unanswered",
75            Self::Note => "worth a look",
76        })
77    }
78}
79
80/// One thing to fix, in the order a reader wants it: what is wrong, then what
81/// to do about it.
82#[derive(Debug, Clone)]
83pub struct Finding {
84    /// How much it matters.
85    pub severity: Severity,
86    /// One line naming what is wrong.
87    pub what: String,
88    /// One line naming what to do.
89    pub fix: String,
90}
91
92/// The whole report for one dialect.
93#[derive(Debug, Clone)]
94pub struct Report {
95    /// The dialect's registry name.
96    pub dialect: String,
97    /// Everything found, worst first.
98    pub findings: Vec<Finding>,
99}
100
101impl Report {
102    /// Whether nothing blocking or unanswered was found.
103    ///
104    /// Notes do not fail a report: they are observations, not obligations.
105    #[must_use]
106    pub fn is_clean(&self) -> bool {
107        !self.findings.iter().any(|f| f.severity != Severity::Note)
108    }
109
110    /// How many findings of one severity.
111    #[must_use]
112    pub fn count(&self, severity: Severity) -> usize {
113        self.findings.iter().filter(|f| f.severity == severity).count()
114    }
115}
116
117impl fmt::Display for Report {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let name = &self.dialect;
120        if self.findings.is_empty() {
121            return writeln!(
122                f,
123                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
124            );
125        }
126        let blocking = self.count(Severity::Blocking);
127        let unanswered = self.count(Severity::Unanswered);
128        let notes = self.count(Severity::Note);
129        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
130        for finding in &self.findings {
131            writeln!(f)?;
132            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
133            writeln!(f, "      → {}", finding.fix)?;
134        }
135        Ok(())
136    }
137}
138
139/// **The report.** Everything about this dialect that Razeri can check by
140/// asking it.
141///
142/// Registration is checked against the dialects linked into this program. A
143/// dialect the host loaded at run time is not among them, so report it with
144/// [`check_with_registry`] and the registry it was loaded into.
145#[must_use]
146pub fn check(dialect: &dyn PlatformDialect) -> Report {
147    check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
148}
149
150/// The same report, against a registry the host built — the path for a dialect
151/// loaded at run time rather than linked in.
152#[must_use]
153pub fn check_with_registry(
154    dialect: &dyn PlatformDialect,
155    registry: &crate::dialect::PlatformRegistry,
156) -> Report {
157    let info = dialect.info();
158    let mut findings = Vec::new();
159
160    check_registration(&info, registry, &mut findings);
161    check_family(info.dialect_family, &mut findings);
162    check_required_rendering(dialect, &mut findings);
163    check_decisions(dialect, &mut findings);
164    check_formula_renderer(dialect, &mut findings);
165    check_evolution(dialect, &mut findings);
166    check_paired_facts(dialect, &mut findings);
167
168    findings.sort_by_key(|f| f.severity);
169    Report { dialect: info.name.to_owned(), findings }
170}
171
172/// Every spelling this dialect answers to has to reach this dialect: its own
173/// name, and each alias it declares.
174///
175/// That is the whole obligation, and it is one the author can meet from their
176/// own crate — the spellings are the dialect's own, so there is no list inside
177/// Razeri to be missing from and nothing here can ask for a change only Razeri
178/// could make. What is worth saying is a spelling that reaches somewhere else:
179/// one another loaded platform also claims (so it names neither, and Razeri
180/// refuses it), or one that reaches a different platform because the registry
181/// holds this dialect under another key.
182fn check_registration(
183    info: &crate::dialect::DialectInfo,
184    registry: &crate::dialect::PlatformRegistry,
185    findings: &mut Vec<Finding>,
186) {
187    let name = info.name;
188    if matches!(
189        crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
190        Err(crate::ResolveError::UnknownDialect { .. })
191    ) {
192        findings.push(Finding {
193            severity: Severity::Blocking,
194            what: format!(
195                "the name `{name}` does not resolve — nothing in this program registered it"
196            ),
197            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
198                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
199                  it that the program using your dialect calls once. A crate nothing links is a \
200                  crate the registry cannot see."
201                .to_owned(),
202        });
203        return;
204    }
205    for spelling in info.spellings() {
206        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
207            Ok(found) if found.info().name == name => {}
208            Ok(found) => findings.push(Finding {
209                severity: Severity::Blocking,
210                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
211                fix: format!(
212                    "the registry holds `{name}` under another key. Register it under its own \
213                     name, or drop `{spelling}` from its aliases."
214                ),
215            }),
216            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
217                severity: Severity::Blocking,
218                what: format!(
219                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
220                     none of them — Razeri refuses that spelling rather than guessing which one \
221                     a design meant",
222                    claimed_by.join(" and ")
223                ),
224                fix: format!(
225                    "give this dialect a name no other platform answers to. A platform name is \
226                     read off the loaded dialects' own declared spellings, so a spelling another \
227                     platform already claims is never `{name}`'s."
228                ),
229            }),
230            Err(_) => findings.push(Finding {
231                severity: Severity::Blocking,
232                what: format!(
233                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
234                ),
235                fix: format!(
236                    "the `{name}` in the registry is a different build of this dialect and does \
237                     not declare `{spelling}`. Register the one you are grading."
238                ),
239            }),
240        }
241    }
242}
243
244/// A family is a claim about spelling, so an unmeasured engine claiming a
245/// measured family's name licenses SQL it may reject.
246fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
247    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
248        findings.push(Finding {
249            severity: Severity::Blocking,
250            what: "this dialect's family is a custom one with no name".to_owned(),
251            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
252                  prints and what a reader looks up."
253                .to_owned(),
254        });
255    }
256}
257
258/// The four required methods must answer for every type a design can declare.
259fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
260    let declarable = declarable_types();
261    let empty_column: Vec<String> = declarable
262        .iter()
263        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
264        .map(|dt| format!("{dt:?}"))
265        .collect();
266    if !empty_column.is_empty() {
267        findings.push(Finding {
268            severity: Severity::Blocking,
269            what: format!(
270                "`render_data_type` answers nothing for {} of the types a design can declare",
271                empty_column.len()
272            ),
273            fix: "every declarable type needs a column spelling on this engine, even if the \
274                  spelling is a wider type that stores it. A blank answer becomes a column \
275                  declaration with no type in it."
276                .to_owned(),
277        });
278    }
279    let empty_cast: Vec<String> = declarable
280        .iter()
281        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
282        .map(|dt| format!("{dt:?}"))
283        .collect();
284    if !empty_cast.is_empty() {
285        findings.push(Finding {
286            severity: Severity::Blocking,
287            what: format!(
288                "`render_cast_type` answers nothing for {} declarable types",
289                empty_cast.len()
290            ),
291            fix: "the generated load opens by casting every source column to its declared type, \
292                  so a blank answer fails the load on its first statement."
293                .to_owned(),
294        });
295    }
296    let quoted = SqlRenderer::quote_identifier(dialect, "order");
297    if quoted == "order" {
298        findings.push(Finding {
299            severity: Severity::Blocking,
300            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
301                   reaches this engine bare"
302                .to_owned(),
303            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
304                  backticks, or brackets."
305                .to_owned(),
306        });
307    }
308    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
309        findings.push(Finding {
310            severity: Severity::Blocking,
311            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
312                   a row with it"
313                .to_owned(),
314            fix: "return this engine's own expression for the current moment.".to_owned(),
315        });
316    }
317}
318
319/// The decisions a dialect states rather than inherits.
320fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
321    let spelling = SqlRenderer::sid_spelling(dialect);
322    if spelling.platform == UNKNOWN_PLATFORM {
323        findings.push(Finding {
324            severity: Severity::Blocking,
325            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
326                   an `xxhash64` its engine very likely does not have"
327                .to_owned(),
328            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
329                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
330                  hash. If it has no portable hash, name an artifact your prerequisites install \
331                  — never the nearest-looking built-in, which keys a warehouse to a number \
332                  nothing else can reproduce."
333                .to_owned(),
334        });
335    }
336
337    // **A stated body that says nothing is worse than no body at all.** Razeri
338    // writes the `CREATE FUNCTION` envelope around whatever comes back, so an
339    // empty one deploys cleanly and returns nothing — every key in the
340    // warehouse is then the sentinel or garbage, under a green deploy.
341    if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
342        && supplied.body.trim().is_empty()
343    {
344        findings.push(Finding {
345            severity: Severity::Blocking,
346            what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
347                   and then supplies an empty one"
348                .to_owned(),
349            fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
350                  one argument, or answer `None` and let Razeri compose the derivation from \
351                  your `sid_spelling`. An empty body deploys without error and keys every row \
352                  to nothing."
353                .to_owned(),
354        });
355    }
356
357    // A file relation that drops its path reads the same file for every entity
358    // — one table filled, the rest filled wrong, and nothing about it fails.
359    let probe = "'/razeri/probe.csv'";
360    if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
361        && !relation.contains(probe)
362    {
363        findings.push(Finding {
364            severity: Severity::Blocking,
365            what: "`delimited_file_relation` returns a relation that does not carry the path it \
366                   was given"
367                .to_owned(),
368            fix: "put the literal you were handed where your engine's file relation takes a \
369                  path. It arrives already quoted and escaped. A relation that drops it reads \
370                  one file for every entity the design binds, and the generated fill loads the \
371                  wrong rows without failing."
372                .to_owned(),
373        });
374    }
375
376    check_routine_forms(dialect, findings);
377    check_slice_replacement(dialect, findings);
378
379    // The note is what a reader of the DDL learns from, and on an engine with
380    // no keyword of its own it is the ONLY place they can learn it.
381    if let ConstraintEnforcement::DeclaredOnly(declared) =
382        DdlRenderer::constraint_enforcement(dialect)
383        && declared.note.split_whitespace().count() < 5
384    {
385        findings.push(Finding {
386            severity: Severity::Blocking,
387            what: "this dialect says its engine accepts constraints it does not enforce, and \
388                   the note explaining that says almost nothing"
389                .to_owned(),
390            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
391                  guarantee. Say what this engine really does with the declaration, and where \
392                  the guarantee has to come from instead."
393                .to_owned(),
394        });
395    }
396
397    // **The two refusals are different findings, and that is the point of the
398    // second variant.** `None` is silence, and silence is unanswered. A stated
399    // `Unsupported` is a measured claim; what is left to grade there is whether
400    // the sentence is worth reading, because it is the only thing an operator
401    // gets where the load would have been.
402    match DialectCapabilities::merge_shape(dialect) {
403        MergeShape::None => findings.push(Finding {
404            severity: Severity::Unanswered,
405            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
406                   load for it"
407                .to_owned(),
408            fix: "if this engine has a MERGE or an upsert statement, say which in \
409                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
410                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
411                  operator can act on, rather than a silence."
412                .to_owned(),
413        }),
414        MergeShape::Unsupported(orientation) => {
415            check_refusal_copy("the upsert refusal", orientation, findings);
416        }
417        MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
418        _ => {}
419    }
420}
421
422/// **A dialect that names a verb for replacing one slice has to put the slice
423/// in it.**
424///
425/// Most dialects answer nothing here, and nothing is a complete answer: Razeri
426/// then writes a delete filtered to the slice, which is correct on every
427/// engine. What is worth grading is the dialect that *does* answer, because its
428/// statement runs in front of a load's write and clears rows. A statement that
429/// forgot the value it was handed does not clear the day this call was given —
430/// it clears some other day, or every day, and the load then reports success
431/// over a table it emptied.
432///
433/// The probe is a daily partition of a date column, which is the case every
434/// partition-level verb is allowed to answer for. A dialect that answers
435/// nothing to it has said the portable form is what it wants.
436fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
437    const VALUE: &str = "$razeri_slice_probe";
438    let plan = StoragePlan {
439        partition: PartitionStrategy::RangeByTimeDimension {
440            field: "razeri_probe_day".to_owned(),
441            granularity: PartitionGranularity::Day,
442            retention: None,
443            look_ahead: None,
444            window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
445        },
446        compression: CompressionStrategy::None,
447        index: IndexStrategy::None,
448    };
449    let day = DataType::date();
450    let columns = ["razeri_probe_day"];
451    let entity = EntityRef {
452        schema: Some("razeri_probe"),
453        name: "razeri_probe_table",
454        is_identity_table: false,
455        columns: &columns,
456        partition_column_type: Some(&day),
457    };
458    let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
459    let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
460    else {
461        return;
462    };
463
464    let carries = match &answer {
465        SliceReplacement::Emptied(statements) => {
466            !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
467        }
468        SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
469    };
470    // **A verb that carries the slice is graded nowhere**, deliberately. The
471    // kit cannot tell a measured answer from a lucky one, and a note on every
472    // dialect that got it right would fire on exactly the dialects that need no
473    // help. What is left to say is said where it is useful — in the trait's own
474    // documentation, which an author reads before writing the override.
475    if carries {
476        return;
477    }
478    findings.push(Finding {
479        severity: Severity::Blocking,
480        what: "`render_slice_replacement` returns a statement that does not carry the slice it \
481               was handed"
482            .to_owned(),
483        fix: "put the value you were given where your statement names the slice, and build any \
484              partition NAME from it the same way your extend procedure built it. A replacement \
485              that drops the value clears a slice this call was not given — or every slice — \
486              and the load reports success over what it emptied."
487            .to_owned(),
488    });
489}
490
491/// **The two halves of an evolution, held together**: what this engine says it
492/// keeps when a loaded table changes shape, and the statement it writes for the
493/// change.
494///
495/// An answer left at its default is graded nowhere, for the reason
496/// [`check_merge_restrictions`] states: from outside, a measured *"my engine
497/// does not do that"* and a question nobody asked read the same, and a finding
498/// made out of that silence would fire on every dialect.
499///
500/// What is worth saying is a **pair that disagrees**. A dialect that says its
501/// engine widens a string in place and then writes no statement for a widening
502/// has told Razeri to call the change safe and left the operator to work out
503/// the `ALTER` alone — a gap that reads to them as Razeri not having finished.
504/// And a dialect that answers `None` to a new column has declined the one
505/// evolution every warehouse has, which is nearly always an override that meant
506/// to narrow something and narrowed everything.
507fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
508    let answers = DialectCapabilities::evolution_answers(dialect);
509    let boolean = DataType::boolean();
510    let long = DataType::varchar(40);
511
512    if DdlRenderer::render_column_evolution(
513        dialect,
514        &ColumnEvolution::AddColumn {
515            schema: Some("s"),
516            table: "t",
517            column: "c",
518            data_type: &boolean,
519        },
520    )
521    .is_none()
522    {
523        findings.push(Finding {
524            severity: Severity::Blocking,
525            what: "this dialect writes no statement for a column that is new, which is the                    commonest change a deployed warehouse ever sees"
526                .to_owned(),
527            fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the                   override and take the contract's `ALTER TABLE … ADD COLUMN …`, which is this                   spelling on every engine Razeri reaches. An engine that genuinely has no way                   to add a column is worth saying out loud in the crate's own documentation."
528                .to_owned(),
529        });
530    }
531
532    if answers.parameterized_string_widening_is_lossless
533        && DdlRenderer::render_column_evolution(
534            dialect,
535            &ColumnEvolution::WidenColumn {
536                schema: Some("s"),
537                table: "t",
538                column: "c",
539                data_type: &long,
540            },
541        )
542        .is_none()
543    {
544        findings.push(Finding {
545            severity: Severity::Note,
546            what: "this dialect says its engine widens a string's declared length in place, and                    writes no statement for the change"
547                .to_owned(),
548            fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this                   engine's spelling of it. Without it Razeri tells an operator the widening                   costs nothing and then hands them nothing to run, which reads as Razeri not                   having finished rather than as a statement they own."
549                .to_owned(),
550        });
551    }
552
553    if answers.nested_field_addition_is_lossless
554        && DdlRenderer::render_column_evolution(
555            dialect,
556            &ColumnEvolution::AddNestedField {
557                schema: Some("s"),
558                table: "t",
559                column: "c",
560                field: "f",
561                data_type: &long,
562            },
563        )
564        .is_none()
565    {
566        findings.push(Finding {
567            severity: Severity::Note,
568            what: "this dialect says its engine keeps every value when a record gains a field,                    and writes no statement for the change"
569                .to_owned(),
570            fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some                   engines name the new field; some restate the whole column's type, which is                   why the change carries both the field's path and the column's type with the                   field in it."
571                .to_owned(),
572        });
573    }
574}
575
576/// **What a stated `MERGE` restriction changes about the SQL Razeri writes for
577/// this engine**, said back to the author while the claim is still cheap to
578/// withdraw.
579///
580/// A restriction left at its default costs nothing and is graded nowhere: the
581/// kit cannot tell a measured *"my engine is fine with this"* from a question
582/// nobody asked, and inventing a finding out of that silence would fire on
583/// every dialect. What it can do is report the restriction a dialect does
584/// state, because each one makes Razeri emit something other than the
585/// statement it emits everywhere else.
586fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
587    if restrictions.slice_pruning_needs_literal {
588        findings.push(Finding {
589            severity: Severity::Note,
590            what: "this dialect says its planner reads one slice of a partitioned table only \
591                   from a literal, so a load scoped to one day cannot simply bind the day"
592                .to_owned(),
593            fix: "Razeri will put the day into the statement text through this engine's \
594                  dynamic-execution form rather than binding it to the load procedure's \
595                  parameter. Confirm that is what your engine wants: the statement is composed \
596                  once per call and its text differs per day, which some planners cache and \
597                  some do not. If a bound parameter does prune here, drop the restriction and \
598                  Razeri binds it like everywhere else."
599                .to_owned(),
600        });
601    }
602}
603
604/// The one override nothing tells an author about, and every formula in every
605/// design goes through it.
606fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
607    let formula = SqlRenderer::to_formula_renderer(dialect);
608    let dialect_name = SqlRenderer::dialect_name(dialect);
609    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
610        findings.push(Finding {
611            severity: Severity::Blocking,
612            what: "`to_formula_renderer` was not overridden, so every formula in every design \
613                   renders through Razeri's ANSI defaults instead of this dialect"
614                .to_owned(),
615            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
616                  formula this engine sees is silently somebody else's."
617                .to_owned(),
618        });
619    }
620}
621
622/// **The three routine grammars a dialect states**, and what each answer owes.
623///
624/// A grammar Razeri carries needs nothing further. A refusal owes words a
625/// reader can act on. `Custom` owes the render method it promised — see
626/// [`check_custom_grammar`].
627fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
628    match SqlRenderer::procedure_form(dialect) {
629        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
630        // annotated form IS its output. Every other dialect that answers it has
631        // simply not said.
632        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
633        ProcedureForm::Agnostic => findings.push(Finding {
634            severity: Severity::Unanswered,
635            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
636                   annotated form — which no engine parses"
637                .to_owned(),
638            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
639                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
640                  offers instead."
641                .to_owned(),
642        }),
643        ProcedureForm::Unsupported(orientation) => {
644            check_refusal_copy("the stored-procedure refusal", orientation, findings);
645        }
646        ProcedureForm::Custom(grammar) => check_custom_grammar(
647            CustomGrammar {
648                kind: RoutineKind::Procedure,
649                grammar,
650                form_method: "procedure_form",
651                render_method: "render_procedure",
652                noun: "procedures",
653            },
654            |spec| SqlRenderer::render_procedure(dialect, spec),
655            findings,
656        ),
657        _ => {}
658    }
659
660    match SqlRenderer::scalar_function_form(dialect) {
661        ScalarFunctionForm::Unsupported(orientation) => {
662            check_refusal_copy("the scalar-function refusal", orientation, findings);
663        }
664        ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
665            CustomGrammar {
666                kind: RoutineKind::ScalarFunction,
667                grammar,
668                form_method: "scalar_function_form",
669                render_method: "render_scalar_function",
670                noun: "scalar functions",
671            },
672            |spec| SqlRenderer::render_scalar_function(dialect, spec),
673            findings,
674        ),
675        _ => {}
676    }
677
678    match SqlRenderer::table_function_form(dialect) {
679        TableFunctionForm::Unsupported(orientation) => {
680            check_refusal_copy("the table-function refusal", orientation, findings);
681        }
682        TableFunctionForm::Custom(grammar) => check_custom_grammar(
683            CustomGrammar {
684                kind: RoutineKind::TableFunction,
685                grammar,
686                form_method: "table_function_form",
687                render_method: "render_table_function",
688                noun: "table functions",
689            },
690            |spec| SqlRenderer::render_table_function(dialect, spec),
691            findings,
692        ),
693        _ => {}
694    }
695
696    check_parameter_type_spelling(dialect, findings);
697}
698
699/// **A parameter's type is not always spelled the way a column's is**, and this
700/// asks whether the dialect has looked.
701///
702/// Oracle is the measured case: a column there takes `NUMBER(19, 0)` and
703/// `VARCHAR2(100)`, and the same types in a parameter list are a compile error
704/// — the bare `NUMBER` and `VARCHAR2` are what a parameter clause takes, with
705/// the precision belonging on the local declaration instead. A dialect that has
706/// not said otherwise gets the column spelling in its parameter lists, which is
707/// right for most engines and a routine that will not compile on one like that.
708///
709/// It is a note and not a failure, because for most engines the two spellings
710/// genuinely are the same and there is nothing to say. And it is asked only of a
711/// dialect spelling a grammar of its own, because that is the population where
712/// nobody has measured the answer yet.
713fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
714    let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
715        || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
716        || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
717    if !spells_its_own {
718        return;
719    }
720    // A type that carries a length is the only kind the two spellings can
721    // disagree about, so it is the only kind worth asking with.
722    let qualified = DataType::varchar(100);
723    let column = SqlRenderer::render_data_type(dialect, &qualified);
724    let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
725    if column == parameter && column.contains('(') {
726        findings.push(Finding {
727            severity: Severity::Note,
728            what: format!(
729                "a parameter of this routine's would be declared `{parameter}`, the same as a                  column of that type"
730            ),
731            fix: "that is right for most engines, so this is only worth a look. If yours refuses                   a length or a precision in a parameter list — Oracle does, and answers                   PLS-00103 at the opening parenthesis — override                   `SqlRenderer::render_parameter_data_type` with the bare spelling. Local                   declarations keep the length either way."
732                .to_owned(),
733        });
734    }
735}
736
737/// One routine kind whose grammar a dialect said it spells itself.
738#[derive(Clone, Copy)]
739struct CustomGrammar {
740    /// Which routine kind the claim is about.
741    kind: RoutineKind,
742    /// The grammar's own name, as the dialect wrote it.
743    grammar: &'static str,
744    /// The method that carried the claim.
745    form_method: &'static str,
746    /// The method that owes the rendering.
747    render_method: &'static str,
748    /// What to call the routines in a sentence.
749    noun: &'static str,
750}
751
752/// **A dialect that says it spells its own grammar has to spell it.**
753///
754/// `Custom` moves the whole rendering to the dialect: Razeri substitutes no
755/// near-neighbour grammar and refuses nothing, because both of those say
756/// something false — one about the engine's syntax, the other about whether the
757/// engine has the routine at all. So the claim is only worth anything with the
758/// method behind it, and a dialect that made the claim and left the method
759/// alone ships a build that stops on the routine.
760///
761/// The check is the question itself: hand the render method the shape it will
762/// receive and see whether anything comes back.
763///
764/// Parameters:
765/// - `claim`: which kind, which grammar, and the two method names to name;
766/// - `render`: the dialect's render method for that kind;
767/// - `findings`: where the report is collected.
768fn check_custom_grammar(
769    claim: CustomGrammar,
770    render: impl Fn(&RoutineSpec) -> Option<String>,
771    findings: &mut Vec<Finding>,
772) {
773    let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
774    // The grammar's name is a name, not a refusal: it is read in a report and
775    // in a comment, so it has to say something and does not have to say much.
776    if grammar.trim().is_empty() {
777        findings.push(Finding {
778            severity: Severity::Blocking,
779            what: format!("`{form_method}` says Custom, and names no grammar"),
780            fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
781                  of a report, and of the comment Razeri writes where a routine could not be \
782                  rendered, is told this engine speaks."
783                .to_owned(),
784        });
785    }
786
787    let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
788    probe.or_replace = true;
789    if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
790        findings.push(Finding {
791            severity: Severity::Blocking,
792            what: format!(
793                "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
794                 itself — and `{render_method}` returns nothing"
795            ),
796            fix: format!(
797                "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
798                 composed — name, parameters with their types already in your spelling, body, \
799                 comment — and returns the whole CREATE statement your engine parses. Razeri \
800                 emits what you return and writes nothing of its own: a near-neighbour grammar \
801                 fails on deploy, and a refusal would say this engine has no {noun} when it \
802                 has them in a grammar of its own."
803            ),
804        });
805    }
806}
807
808/// A refusal that does not say what to do instead is a wall.
809fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
810    if copy.trim().is_empty() {
811        findings.push(Finding {
812            severity: Severity::Blocking,
813            what: format!("{subject} has no words in it"),
814            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
815                  next thing to try, and it has to be about their engine."
816                .to_owned(),
817        });
818        return;
819    }
820    if copy.split_whitespace().count() < 5 {
821        findings.push(Finding {
822            severity: Severity::Note,
823            what: format!("{subject} is very short"),
824            fix: "a refusal earns its place by naming the alternative — what this engine has \
825                  instead of the thing it cannot do."
826                .to_owned(),
827        });
828    }
829}
830
831/// Fourteen facts are declared twice and must agree.
832fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
833    let caps: &dyn DialectCapabilities = dialect;
834    let ren: &dyn SqlRenderer = dialect;
835    let ddl: &dyn DdlRenderer = dialect;
836    let mut disagreements: Vec<&'static str> = Vec::new();
837
838    macro_rules! pair {
839        ($m:ident, $renderer_value:expr) => {
840            if caps.$m() != $renderer_value {
841                disagreements.push(stringify!($m));
842            }
843        };
844    }
845    pair!(supports_on_conflict, ren.supports_on_conflict());
846    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
847    pair!(supports_update_from, ren.supports_update_from());
848    pair!(supports_named_windows, ren.supports_named_windows());
849    pair!(supports_compound_assignment, ren.supports_compound_assignment());
850    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
851    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
852    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
853    pair!(uses_alter_table_update, ren.uses_alter_table_update());
854    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
855    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
856    pair!(supports_sync_drop, ddl.supports_sync_drop());
857    pair!(supports_force_drop, ddl.supports_force_drop());
858
859    // **The fourteenth, whose two sides are different types.** The capability
860    // is a boolean; the renderer answer is a form that either renders or
861    // carries a refusal. Both are read — the build door reads the form, and
862    // registry-walking rosters read the capability — so a dialect claiming a
863    // table function on one side and refusing on the other has an engine that
864    // both does and does not have one. `SQLite` was exactly that on the day
865    // this check was written: `false` since the capability existed, and a
866    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
867    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
868        disagreements.push("supports_table_functions");
869    }
870
871    if !disagreements.is_empty() {
872        findings.push(Finding {
873            severity: Severity::Blocking,
874            what: format!(
875                "this dialect declares {} fact(s) two ways and the two disagree: {}",
876                disagreements.len(),
877                disagreements.join(", ")
878            ),
879            fix: "each of these is declared once on the capability trait and once in the \
880                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
881                  and move the other one to match."
882                .to_owned(),
883        });
884    }
885}
886
887/// Every type a design can declare, which is the set the required methods owe
888/// an answer for.
889///
890/// The last four are the ones whose spelling actually varies between engines:
891/// an array, a map, a struct, and an array of structs. A dialect that answers
892/// only for scalars is a dialect that has not been graded on the types a
893/// nested source table is made of.
894fn declarable_types() -> Vec<DataType> {
895    vec![
896        DataType::tinyint(),
897        DataType::smallint(),
898        DataType::int(),
899        DataType::bigint(),
900        DataType::double(),
901        DataType::boolean(),
902        DataType::text(),
903        DataType::varchar(200),
904        DataType::uuid(),
905        DataType::json(),
906        DataType::date(),
907        DataType::timestamp(),
908        DataType::timestamp_tz(),
909        DataType::decimal(18, 2),
910        DataType::Array(ArrayDataType::new(DataType::varchar(100))),
911        DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
912        DataType::Struct(StructDataType::new(vec![
913            StructField::new("street", DataType::varchar(200)),
914            StructField::new("zip", DataType::varchar(10)),
915        ])),
916        DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
917            StructField::new("key", DataType::varchar(100)),
918            StructField::new("value", DataType::varchar(1000)),
919        ])))),
920    ]
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use crate::dialect::DialectInfo;
927    use razeri_plugin_contract::{
928        Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
929        ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
930        TaskRenderer, TextCast,
931    };
932    use std::marker::PhantomData;
933
934    // ── The broken fixture ────────────────────────────────────────────
935    //
936    // A plugin that compiles, registers nothing it should, and answers every
937    // question with somebody else's default. This is what the kit exists to
938    // catch, and it is deliberately the plugin an author gets by implementing
939    // only the four required methods and stopping — which the guide, until
940    // now, told them was enough.
941
942    struct BrokenPlugin;
943
944    impl SqlRenderer for BrokenPlugin {
945        fn render_data_type(&self, _dt: &DataType) -> String {
946            String::new()
947        }
948        fn quote_identifier(&self, name: &str) -> String {
949            name.to_owned()
950        }
951        fn render_cast_type(&self, _dt: &DataType) -> String {
952            String::new()
953        }
954        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
955            name.to_owned()
956        }
957        fn render_current_timestamp(&self) -> String {
958            String::new()
959        }
960        fn dialect_name(&self) -> &'static str {
961            "brokendb"
962        }
963        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
964        // and one fact declared twice, two ways.
965        fn supports_on_conflict(&self) -> bool {
966            true
967        }
968    }
969    impl ExpressionRenderer for BrokenPlugin {}
970    impl DdlRenderer for BrokenPlugin {}
971    impl DmlRenderer for BrokenPlugin {}
972    impl ProcedureRenderer for BrokenPlugin {}
973    impl FormulaRenderer for BrokenPlugin {}
974    impl TaskRenderer for BrokenPlugin {}
975    impl ReplicationRenderer for BrokenPlugin {}
976    impl StorageRenderer for BrokenPlugin {}
977    impl DialectCapabilities for BrokenPlugin {
978        fn supports_on_conflict(&self) -> bool {
979            false
980        }
981    }
982    impl PlatformDialect for BrokenPlugin {
983        fn info(&self) -> DialectInfo {
984            DialectInfo {
985                name: "brokendb",
986                display_name: "Broken DB",
987                aliases: &[],
988                dialect_family: DialectFamily::Custom(""),
989                family: "Broken",
990                is_cloud_native: false,
991                is_olap: false,
992                is_postgres_compatible: false,
993            }
994        }
995    }
996
997    // ── The answered fixture, under two names ─────────────────────────
998    //
999    // The same plugin with every question answered, carrying the name it
1000    // registers under as a type parameter. Neither name is one Razeri knows a
1001    // spelling for; the difference is that one of them is in the registry and
1002    // the other is in nothing at all. Those are two different facts about a
1003    // dialect, and they read as two different reports.
1004
1005    trait FixtureName: Send + Sync + 'static {
1006        /// The registry name this fixture answers to.
1007        const NAME: &'static str;
1008        /// How this fixture's engine joins the parts of a composite key. Most
1009        /// of them take the inherited variadic `CONCAT`; the one that does not
1010        /// is the point of it being a knob.
1011        const CONCAT: Concat = Concat::Function("CONCAT");
1012    }
1013
1014    /// Answered, and nothing linked it — what an author sees running the report
1015    /// from a crate whose registration nobody calls.
1016    struct Unlinked;
1017    impl FixtureName for Unlinked {
1018        const NAME: &'static str = "healthydb";
1019    }
1020
1021    /// Answered and registered, under a name Razeri ships no spelling for —
1022    /// the shape of every dialect written outside Razeri's own tree.
1023    struct Outside;
1024    impl FixtureName for Outside {
1025        const NAME: &'static str = "outsidedb";
1026    }
1027
1028    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
1029    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
1030    /// about the join is an obligation, so the report on it must read exactly
1031    /// like the report on a variadic engine.
1032    struct BinaryConcat;
1033    impl FixtureName for BinaryConcat {
1034        const NAME: &'static str = "binaryconcatdb";
1035        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1036    }
1037
1038    struct Answered<N: FixtureName>(PhantomData<N>);
1039
1040    impl<N: FixtureName> Answered<N> {
1041        const fn new() -> Self {
1042            Self(PhantomData)
1043        }
1044    }
1045
1046    type HealthyPlugin = Answered<Unlinked>;
1047    type OutsidePlugin = Answered<Outside>;
1048
1049    inventory::submit! {
1050        crate::builder::DialectRegistration::new(
1051            Outside::NAME,
1052            || std::sync::Arc::new(OutsidePlugin::new()),
1053        )
1054    }
1055
1056    impl<N: FixtureName> SqlRenderer for Answered<N> {
1057        fn render_data_type(&self, dt: &DataType) -> String {
1058            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1059        }
1060        fn quote_identifier(&self, name: &str) -> String {
1061            format!("\"{name}\"")
1062        }
1063        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1064            schema.map_or_else(
1065                || self.quote_identifier(name),
1066                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1067            )
1068        }
1069        fn render_current_timestamp(&self) -> String {
1070            "NOW()".to_owned()
1071        }
1072        fn dialect_name(&self) -> &'static str {
1073            N::NAME
1074        }
1075        fn sid_spelling(&self) -> SidSpelling {
1076            SidSpelling {
1077                platform: N::NAME,
1078                hash: ("answered_xxhash64(", ")"),
1079                cast_type: TextCast::Direct("STRING"),
1080                concat: N::CONCAT,
1081                through_helper: false,
1082                ..razeri_plugin_contract::ANSI_SID_SPELLING
1083            }
1084        }
1085        fn procedure_form(&self) -> ProcedureForm {
1086            ProcedureForm::Unsupported(
1087                "This engine has no stored procedures; write the load as a scheduled query \
1088                 instead.",
1089            )
1090        }
1091        fn scalar_function_form(&self) -> ScalarFunctionForm {
1092            ScalarFunctionForm::PostgreSql
1093        }
1094        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1095            Box::new(Self::new())
1096        }
1097    }
1098    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1099    impl<N: FixtureName> DdlRenderer for Answered<N> {}
1100    impl<N: FixtureName> DmlRenderer for Answered<N> {}
1101    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1102    impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1103    impl<N: FixtureName> TaskRenderer for Answered<N> {}
1104    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1105    impl<N: FixtureName> StorageRenderer for Answered<N> {}
1106    impl<N: FixtureName> DialectCapabilities for Answered<N> {
1107        fn merge_shape(&self) -> MergeShape {
1108            MergeShape::Merge(MergeRestrictions::none())
1109        }
1110    }
1111    impl<N: FixtureName> PlatformDialect for Answered<N> {
1112        fn info(&self) -> DialectInfo {
1113            DialectInfo {
1114                name: N::NAME,
1115                display_name: "An Answered Engine",
1116                // It answers to its own name and nothing else, which is the
1117                // shape of a plugin that declares no extra spellings.
1118                aliases: &[],
1119                dialect_family: DialectFamily::Custom(N::NAME),
1120                family: "Answered",
1121                is_cloud_native: false,
1122                is_olap: false,
1123                is_postgres_compatible: false,
1124            }
1125        }
1126    }
1127
1128    // ── The twin: a dialect that claims a name another platform answers to ──
1129    //
1130    // Answered in every other respect, so the only thing its report can be
1131    // about is the spelling it took.
1132
1133    struct Twin;
1134
1135    impl SqlRenderer for Twin {
1136        fn render_data_type(&self, dt: &DataType) -> String {
1137            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1138        }
1139        fn quote_identifier(&self, name: &str) -> String {
1140            format!("\"{name}\"")
1141        }
1142        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1143            self.quote_identifier(name)
1144        }
1145        fn render_current_timestamp(&self) -> String {
1146            "NOW()".to_owned()
1147        }
1148    }
1149    impl ExpressionRenderer for Twin {}
1150    impl DdlRenderer for Twin {}
1151    impl DmlRenderer for Twin {}
1152    impl ProcedureRenderer for Twin {}
1153    impl FormulaRenderer for Twin {}
1154    impl TaskRenderer for Twin {}
1155    impl ReplicationRenderer for Twin {}
1156    impl StorageRenderer for Twin {}
1157    impl DialectCapabilities for Twin {}
1158    impl PlatformDialect for Twin {
1159        fn info(&self) -> DialectInfo {
1160            DialectInfo {
1161                name: "twindb",
1162                display_name: "Twin DB",
1163                // The name the answered fixture already answers to.
1164                aliases: &["healthydb"],
1165                dialect_family: DialectFamily::Custom("twindb"),
1166                family: "Twin",
1167                is_cloud_native: false,
1168                is_olap: false,
1169                is_postgres_compatible: false,
1170            }
1171        }
1172    }
1173
1174    /// Every finding whose `what` line mentions one of these subjects.
1175    fn mentions(report: &Report, needle: &str) -> bool {
1176        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1177    }
1178
1179    /// **The kit's own red.** A plugin that implements the four required
1180    /// methods and stops is exactly what the guide used to produce, and every
1181    /// one of these findings is a defect a shipped dialect actually had.
1182    #[test]
1183    fn the_kit_catches_a_deliberately_broken_plugin() {
1184        let report = check(&BrokenPlugin);
1185        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1186
1187        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1188        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1189        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1190        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1191        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1192        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1193        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1194        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1195        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1196        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1197        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1198
1199        assert!(
1200            report.count(Severity::Blocking) >= 8,
1201            "the broken plugin owes more than {} blocking fixes:\n{report}",
1202            report.count(Severity::Blocking)
1203        );
1204    }
1205
1206    /// **The kit does not cry wolf.** The same plugin with its questions
1207    /// answered reports only what is actually true of it: it is not
1208    /// registered in this test binary, and nothing else.
1209    #[test]
1210    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1211        let report = check(&HealthyPlugin::new());
1212        let unregistered: Vec<&Finding> =
1213            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1214        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1215        assert_eq!(
1216            report.findings.len(),
1217            1,
1218            "a plugin that answered every question owes nothing else:\n{report}"
1219        );
1220    }
1221
1222    /// **A join written for a two-argument `CONCAT` is an answer, not a
1223    /// shortfall.** The report on a dialect that states it reads exactly like
1224    /// the report on a variadic one, and the key it renders nests pairwise
1225    /// instead of widening a call the engine does not have.
1226    #[test]
1227    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1228        let binary = Answered::<BinaryConcat>::new();
1229        let report = check(&binary);
1230        assert_eq!(
1231            report.findings.len(),
1232            check(&HealthyPlugin::new()).findings.len(),
1233            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1234        );
1235        assert!(
1236            !report.to_string().contains("joins"),
1237            "nothing about the join is outstanding:\n{report}"
1238        );
1239
1240        let key = razeri_plugin_contract::sid::sid_expression(
1241            SqlRenderer::sid_spelling(&binary),
1242            None,
1243            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1244        );
1245        assert!(
1246            key.contains("CONCAT(CONCAT("),
1247            "a two-argument engine gets one call per pair:\n{key}"
1248        );
1249    }
1250
1251    /// **A stated `MERGE` restriction is reported; a default one is not.**
1252    ///
1253    /// The asymmetry is the point. A dialect that says its planner needs a
1254    /// literal slice gets Razeri's plan for that answer back, while the engine
1255    /// it is describing is still in front of the author. A dialect that has
1256    /// said nothing gets nothing, because the kit cannot tell a measured
1257    /// *"a parameter is fine here"* from a question never asked, and a finding
1258    /// made out of that silence would fire on every dialect alike.
1259    #[test]
1260    fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1261        let mut quiet = Vec::new();
1262        check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1263        assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1264
1265        let mut stated = Vec::new();
1266        check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1267        assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1268        assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1269        assert!(
1270            stated[0].what.contains("literal"),
1271            "the finding names the restriction:\n{}",
1272            stated[0].what
1273        );
1274        assert!(
1275            stated[0].fix.contains("dynamic-execution"),
1276            "and says what Razeri does with the answer:\n{}",
1277            stated[0].fix
1278        );
1279
1280        // The other restriction travels on the same struct and must not drag
1281        // the new finding along with it.
1282        let mut two_clauses = Vec::new();
1283        check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1284        assert!(two_clauses.is_empty(), "one restriction is not the other");
1285    }
1286
1287    /// **A dialect written outside Razeri reaches a clean report.** It is
1288    /// reached the way every out-of-tree dialect is reached — it registered, and
1289    /// the resolver reads the loaded dialects' own declared spellings — so there
1290    /// is nothing for Razeri to be missing it from and nothing for the report to
1291    /// ask of its author.
1292    #[test]
1293    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1294        assert!(
1295            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1296            "the fixture registers through the registry, which is the whole path being graded"
1297        );
1298        // The precondition that used to read "no row in Razeri's table": no
1299        // other loaded platform declares this name, so the fixture is reached
1300        // only because it registered and said so itself.
1301        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1302        assert!(
1303            registry
1304                .list()
1305                .iter()
1306                .filter(|info| info.name != Outside::NAME)
1307                .all(|info| !info.answers_to(Outside::NAME)),
1308            "the fixture's name must be one no other platform claims"
1309        );
1310
1311        let report = check(&OutsidePlugin::new());
1312        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1313        assert!(report.is_clean(), "and the report says so:\n{report}");
1314        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1315        assert!(
1316            report.to_string().contains("nothing to fix"),
1317            "a clean dialect is told it is clean:\n{report}"
1318        );
1319    }
1320
1321    /// **A platform cannot be reachable under an alias but not under its own
1322    /// name.** That used to be a judgement about a hand-written table, which
1323    /// could spell a platform `mynew_db` and never `mynewdb`. A dialect now
1324    /// declares its own spellings and its own name leads them, so the defect is
1325    /// unrepresentable rather than caught — and this is the assertion that keeps
1326    /// it that way.
1327    #[test]
1328    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1329        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1330        for info in registry.list() {
1331            assert_eq!(
1332                info.spellings().first().copied(),
1333                Some(info.name),
1334                "a platform's own name leads its spellings"
1335            );
1336            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1337        }
1338    }
1339
1340    /// **A name another platform already answers to is a collision, not a
1341    /// registration.** A dialect claiming a spelling Healthy DB answers to is
1342    /// named as the collision it is — with both platforms named, and a fix its
1343    /// author can actually perform — rather than being reported as an
1344    /// unregistered platform, which is a different problem with a different fix.
1345    #[test]
1346    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1347        let mut registry = crate::dialect::PlatformRegistry::new();
1348        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1349        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1350
1351        let report = check_with_registry(&Twin, &registry);
1352        let collision = report
1353            .findings
1354            .iter()
1355            .find(|f| f.what.contains("claimed by more than one loaded platform"))
1356            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1357        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1358        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1359        assert!(collision.what.contains("twindb"), "{collision:?}");
1360        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1361        assert!(
1362            !mentions(&report, "does not resolve"),
1363            "a collision is not an unregistered platform:\n{report}"
1364        );
1365    }
1366
1367    /// A platform reached under its own name owes nothing, whatever the case or
1368    /// the surrounding whitespace, and a name nothing has ever heard of is
1369    /// simply unknown rather than a finding against this dialect.
1370    #[test]
1371    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1372        let mut registry = crate::dialect::PlatformRegistry::new();
1373        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1374
1375        let mut findings = Vec::new();
1376        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
1377        assert!(findings.is_empty(), "{findings:?}");
1378
1379        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
1380            assert!(
1381                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
1382                "'{spelling}' is the same platform"
1383            );
1384        }
1385        assert!(
1386            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
1387        );
1388    }
1389
1390    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
1391    /// asks the dialects linked into this program, which one loaded from a
1392    /// shared library is not among; [`check_with_registry`] asks the registry it
1393    /// was loaded into, and the registration judgement is answered from there.
1394    #[test]
1395    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1396        let mut registry = crate::dialect::PlatformRegistry::new();
1397        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1398
1399        let report = check_with_registry(&HealthyPlugin::new(), &registry);
1400        assert!(
1401            report.is_clean(),
1402            "a loaded dialect that answered everything owes nothing:\n{report}"
1403        );
1404    }
1405
1406    /// **Every fix an outside author can read is a fix in their own crate.** A
1407    /// report that names a file inside Razeri is telling an author to edit a
1408    /// tree they do not have, and the fix cannot be performed at all.
1409    #[test]
1410    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1411        let mut collided = crate::dialect::PlatformRegistry::new();
1412        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1413        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1414
1415        let mut fixes: Vec<String> = Vec::new();
1416        for report in [
1417            check(&BrokenPlugin),
1418            check(&HealthyPlugin::new()),
1419            check(&OutsidePlugin::new()),
1420            // The collision and the unregistered-name judgements, which are the
1421            // two registration findings an author can actually reach.
1422            check_with_registry(&Twin, &collided),
1423            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1424        ] {
1425            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1426        }
1427        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1428        for fix in &fixes {
1429            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1430                assert!(
1431                    !fix.contains(path),
1432                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1433                );
1434            }
1435        }
1436    }
1437
1438    /// **A dialect that claims its own grammar and does not spell it is
1439    /// blocking**, and the finding names the method it owes.
1440    ///
1441    /// `Custom` is a promise that the dialect renders the routine itself, and
1442    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
1443    /// which fails on deploy, and no refusal, which would tell an author their
1444    /// engine has no routines when it has a whole language. A promise with
1445    /// nothing behind it is therefore a build that stops, and it has to be
1446    /// findable before the deploy rather than during it.
1447    #[test]
1448    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1449        for (kind, form_method, render_method) in [
1450            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1451            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1452            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1453        ] {
1454            let mut promised = Vec::new();
1455            check_custom_grammar(
1456                CustomGrammar {
1457                    kind,
1458                    grammar: "PL/SQL",
1459                    form_method,
1460                    render_method,
1461                    noun: "routines",
1462                },
1463                |_| None,
1464                &mut promised,
1465            );
1466            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1467            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1468            assert!(promised[0].what.contains(form_method), "{promised:?}");
1469            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1470            assert!(promised[0].fix.contains(render_method), "{promised:?}");
1471
1472            // The same claim, kept: nothing to report.
1473            let mut kept = Vec::new();
1474            check_custom_grammar(
1475                CustomGrammar {
1476                    kind,
1477                    grammar: "PL/SQL",
1478                    form_method,
1479                    render_method,
1480                    noun: "routines",
1481                },
1482                |spec| Some(format!("CREATE {} …", spec.name)),
1483                &mut kept,
1484            );
1485            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1486        }
1487    }
1488
1489    /// **A render method that answers with nothing at all is the same defect.**
1490    ///
1491    /// An override that returns an empty string is a routine razeri would emit
1492    /// as blank space in the middle of a schema, which is worse than an error:
1493    /// nothing in the deploy says anything is missing.
1494    #[test]
1495    fn a_custom_render_method_that_returns_nothing_is_blocking() {
1496        let mut findings = Vec::new();
1497        check_custom_grammar(
1498            CustomGrammar {
1499                kind: RoutineKind::Procedure,
1500                grammar: "PL/SQL",
1501                form_method: "procedure_form",
1502                render_method: "render_procedure",
1503                noun: "procedures",
1504            },
1505            |_| Some("   \n".to_owned()),
1506            &mut findings,
1507        );
1508        assert_eq!(findings.len(), 1, "{findings:?}");
1509        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1510    }
1511
1512    /// **A `Custom` grammar with no name gives a reader nothing to read.**
1513    #[test]
1514    fn a_custom_grammar_with_no_name_is_blocking() {
1515        let mut findings = Vec::new();
1516        check_custom_grammar(
1517            CustomGrammar {
1518                kind: RoutineKind::ScalarFunction,
1519                grammar: "  ",
1520                form_method: "scalar_function_form",
1521                render_method: "render_scalar_function",
1522                noun: "scalar functions",
1523            },
1524            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1525            &mut findings,
1526        );
1527        assert_eq!(findings.len(), 1, "{findings:?}");
1528        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1529    }
1530
1531    /// **A stated fact that is empty, and a stated fact that ignores its
1532    /// argument.**
1533    ///
1534    /// Both answers deploy without an error and are wrong in the data: an empty
1535    /// helper body keys every row to nothing, and a file relation that drops
1536    /// its path reads one file for every entity a design binds. Neither is
1537    /// visible anywhere else, which is why the kit grades them.
1538    #[test]
1539    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1540        struct Overstated;
1541        impl SqlRenderer for Overstated {
1542            fn render_data_type(&self, dt: &DataType) -> String {
1543                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1544            }
1545            fn quote_identifier(&self, name: &str) -> String {
1546                format!("\"{name}\"")
1547            }
1548            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1549                name.to_owned()
1550            }
1551            fn render_current_timestamp(&self) -> String {
1552                "CURRENT_TIMESTAMP".to_owned()
1553            }
1554            fn dialect_name(&self) -> &'static str {
1555                "overstateddb"
1556            }
1557            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1558                Some(razeri_plugin_contract::SidHelperBody {
1559                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1560                    body: "   \n  ",
1561                })
1562            }
1563            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1564                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1565            }
1566        }
1567        impl ExpressionRenderer for Overstated {}
1568        impl DdlRenderer for Overstated {}
1569        impl DmlRenderer for Overstated {}
1570        impl ProcedureRenderer for Overstated {}
1571        impl FormulaRenderer for Overstated {}
1572        impl TaskRenderer for Overstated {}
1573        impl ReplicationRenderer for Overstated {}
1574        impl StorageRenderer for Overstated {}
1575        impl DialectCapabilities for Overstated {}
1576        impl PlatformDialect for Overstated {
1577            fn info(&self) -> DialectInfo {
1578                DialectInfo {
1579                    name: "overstateddb",
1580                    display_name: "Overstated DB",
1581                    aliases: &[],
1582                    dialect_family: DialectFamily::Custom(""),
1583                    family: "Overstated",
1584                    is_cloud_native: false,
1585                    is_olap: false,
1586                    is_postgres_compatible: false,
1587                }
1588            }
1589        }
1590
1591        let report = check(&Overstated);
1592        for (fragment, why) in [
1593            ("supplies an empty one", "a blank helper body"),
1594            ("does not carry the path", "a file relation that drops its path"),
1595        ] {
1596            let found = report
1597                .findings
1598                .iter()
1599                .find(|f| f.what.contains(fragment))
1600                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1601            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1602        }
1603
1604        // And silence is not a finding. The fully-answered fixture states
1605        // neither fact — the majority answer — and owes nothing for it.
1606        let quiet = check(&HealthyPlugin::new());
1607        for fragment in ["supplies an empty one", "does not carry the path"] {
1608            assert!(
1609                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1610                "silence is the default and owes nothing:\n{quiet}"
1611            );
1612        }
1613    }
1614
1615    /// **A stated refusal is a claim, and a claim owes the reader words** — the
1616    /// same grading every other refusal gets, because these words are the only
1617    /// thing an operator finds where the load would have been.
1618    #[test]
1619    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1620        let mut mute = Vec::new();
1621        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1622        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1623        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1624
1625        let silence = check(&BrokenPlugin);
1626        assert!(
1627            silence
1628                .findings
1629                .iter()
1630                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1631            "a dialect that never answered is unanswered, not wrong:\n{silence}"
1632        );
1633        assert!(
1634            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1635            "the fix line names the way to state a measured refusal:\n{silence}"
1636        );
1637    }
1638
1639    /// The report leads with what to fix and says it in words a reader can
1640    /// act on — no identifiers from Razeri's own record-keeping, and a
1641    /// one-line count before the detail.
1642    #[test]
1643    fn the_report_reads_as_a_summary_then_a_fix_list() {
1644        let text = check(&BrokenPlugin).to_string();
1645        let first = text.lines().next().expect("a summary line");
1646        assert!(first.starts_with("brokendb:"), "{first}");
1647        assert!(first.contains("to fix"), "{first}");
1648        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
1649    }
1650}