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