Skip to main content

razeri_plugin_api/
resolve.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//! Canonical dialect resolver with alias support and near-miss suggestions.
6//!
7//! All service crates (CLI, API, MCP, AI, pipeline) should call
8//! [`resolve_dialect_by_name`] instead of maintaining their own match blocks.
9
10use crate::PlatformDialect;
11use crate::dialect::PlatformRegistry;
12use std::sync::Arc;
13use thiserror::Error;
14
15/// Error returned when a dialect name cannot be resolved.
16#[derive(Debug, Error)]
17pub enum ResolveError {
18    /// The name did not match any registered dialect or known alias.
19    #[error(
20        "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
21        suggestions_text = if suggestions.is_empty() {
22            String::new()
23        } else {
24            format!("Did you mean: {}? ", suggestions.join(", "))
25        },
26        registered = registered.join(", ")
27    )]
28    UnknownDialect {
29        /// The name that was looked up.
30        name: String,
31        /// Near-miss suggestions (by edit distance).
32        suggestions: Vec<String>,
33        /// All registered dialect names.
34        registered: Vec<String>,
35    },
36}
37
38/// Alias table mapping user-facing names to canonical dialect names.
39///
40/// The canonical name must match `DialectInfo::name` of a registered dialect.
41/// Order does not matter — the first match on the left column wins.
42const ALIAS_TABLE: &[(&str, &str)] = &[
43    // PostgreSQL
44    ("postgres", "postgres"),
45    ("postgresql", "postgres"),
46    ("pg", "postgres"),
47    // Snowflake
48    ("snowflake", "snowflake"),
49    ("sf", "snowflake"),
50    // SQL Server / T-SQL
51    ("sqlserver", "sqlserver"),
52    ("tsql", "sqlserver"),
53    ("mssql", "sqlserver"),
54    ("sql_server", "sqlserver"),
55    // Databricks / Spark
56    ("databricks", "databricks"),
57    ("dbx", "databricks"),
58    ("spark", "databricks"),
59    // DuckDB
60    ("duckdb", "duckdb"),
61    ("duck", "duckdb"),
62    // ClickHouse
63    ("clickhouse", "clickhouse"),
64    ("ch", "clickhouse"),
65    // MariaDB / MySQL
66    ("mariadb", "mariadb"),
67    ("mariadb_columnstore", "mariadb"),
68    ("mysql", "mysql"),
69    // CitusDB
70    ("citusdb", "citus"),
71    ("citus", "citus"),
72    // Agnostic (Razeri's universal cross-database dialect)
73    ("agnostic", "agnostic"),
74    // SQLite (extension dialect)
75    ("sqlite", "sqlite"),
76    // Google BigQuery
77    ("bigquery", "bigquery"),
78    ("big_query", "bigquery"),
79    ("bq", "bigquery"),
80    // Amazon Redshift
81    ("redshift", "redshift"),
82    ("amazon_redshift", "redshift"),
83    ("rs", "redshift"),
84];
85
86/// Resolve a dialect by user-facing name string.
87///
88/// Performs case-insensitive matching with alias support. Falls back to the
89/// [`PlatformRegistry`] for any name not in the alias table (e.g. third-party
90/// extension plugins).
91///
92/// # Errors
93///
94/// Returns [`ResolveError::UnknownDialect`] with near-miss suggestions if the
95/// name is not recognized.
96///
97/// # Examples
98///
99/// ```no_run
100/// use razeri_plugin_api::ResolveError;
101/// use razeri_plugin_api::resolve::resolve_dialect_by_name;
102///
103/// # fn main() -> Result<(), ResolveError> {
104/// let dialect = resolve_dialect_by_name("pg")?;
105/// println!("{}", dialect.info().name);
106///
107/// let dialect = resolve_dialect_by_name("SNOWFLAKE")?;
108/// # Ok(())
109/// # }
110/// ```
111pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
112    let registry = PlatformRegistry::with_auto_discovered();
113    resolve_dialect_by_name_with_registry(name, &registry)
114}
115
116/// Resolve using an explicit registry (useful for testing and composition root).
117///
118/// # Errors
119///
120/// Returns an error if the operation fails.
121pub fn resolve_dialect_by_name_with_registry(
122    name: &str,
123    registry: &PlatformRegistry,
124) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
125    let normalized = name.trim().to_lowercase();
126
127    // 1. Try alias table → canonical name → registry
128    if let Some(canonical) = alias_lookup(&normalized)
129        && let Some(dialect) = registry.get_by_name(canonical)
130    {
131        return Ok(Arc::clone(dialect));
132    }
133
134    // 2. Try direct registry lookup (covers third-party extensions not in alias table)
135    if let Some(dialect) = registry.get_by_name(&normalized) {
136        return Ok(Arc::clone(dialect));
137    }
138
139    // 3. Build error with suggestions
140    let all_names = registered_names(registry);
141    let suggestions = near_misses(&normalized, &all_names);
142    Err(ResolveError::UnknownDialect { name: name.to_string(), suggestions, registered: all_names })
143}
144
145/// **The accepted spellings, as `(spelling, canonical name)` pairs.**
146///
147/// A design names its platform in whatever spelling its author reaches for, and
148/// this is the table that turns one into the other. A platform whose own name
149/// is missing from it is refused at the CLI door however well its dialect
150/// renders.
151#[must_use]
152pub const fn alias_spellings() -> &'static [(&'static str, &'static str)] {
153    ALIAS_TABLE
154}
155
156/// List all names accepted by the resolver (alias table + registered names).
157#[must_use]
158pub fn all_accepted_names() -> Vec<String> {
159    let registry = PlatformRegistry::with_auto_discovered();
160    let mut names: Vec<String> =
161        ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
162    for info in registry.list() {
163        let n = info.name.to_string();
164        if !names.contains(&n) {
165            names.push(n);
166        }
167    }
168    names.sort();
169    names.dedup();
170    names
171}
172
173// ── Internal helpers ────────────────────────────────────────────────────
174
175/// Look up an alias (already lowercased) in the alias table.
176fn alias_lookup(normalized: &str) -> Option<&'static str> {
177    ALIAS_TABLE.iter().find(|(alias, _)| *alias == normalized).map(|(_, canonical)| *canonical)
178}
179
180/// Collect all registered dialect names from the registry.
181fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
182    let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
183    names.sort();
184    names
185}
186
187/// Find near-miss suggestions using Levenshtein distance ≤ 3.
188fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
189    let mut scored: Vec<(&str, usize)> = candidates
190        .iter()
191        .filter_map(|c| {
192            let dist = levenshtein(input, c);
193            if dist <= 3 { Some((c.as_str(), dist)) } else { None }
194        })
195        .collect();
196    scored.sort_by_key(|(_, d)| *d);
197    scored.into_iter().map(|(name, _)| name.to_string()).collect()
198}
199
200/// Simple Levenshtein distance (no allocation beyond the DP row).
201fn levenshtein(a: &str, b: &str) -> usize {
202    let a_bytes = a.as_bytes();
203    let b_bytes = b.as_bytes();
204    let m = a_bytes.len();
205    let n = b_bytes.len();
206
207    let mut prev: Vec<usize> = (0..=n).collect();
208    let mut curr = vec![0; n + 1];
209
210    for i in 1..=m {
211        curr[0] = i;
212        for j in 1..=n {
213            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
214            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
215        }
216        std::mem::swap(&mut prev, &mut curr);
217    }
218    prev[n]
219}
220
221// ── Also include alias lookup in the ALIAS_TABLE entries ────────────────
222/// All known aliases (for documentation and help messages).
223#[must_use]
224pub fn all_aliases() -> &'static [(&'static str, &'static str)] {
225    ALIAS_TABLE
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
232    use razeri_plugin_contract::DataType;
233    use razeri_plugin_contract::DialectFamily;
234    use razeri_plugin_contract::{
235        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
236        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
237    };
238
239    /// Minimal test dialect.
240    struct StubDialect {
241        info: DialectInfo,
242    }
243
244    impl SqlRenderer for StubDialect {
245        fn render_data_type(&self, _dt: &DataType) -> String {
246            String::new()
247        }
248        fn quote_identifier(&self, name: &str) -> String {
249            format!("\"{name}\"")
250        }
251        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
252            self.quote_identifier(name)
253        }
254        fn render_current_timestamp(&self) -> String {
255            String::new()
256        }
257    }
258    impl ExpressionRenderer for StubDialect {}
259    impl DdlRenderer for StubDialect {}
260    impl DmlRenderer for StubDialect {}
261    impl ProcedureRenderer for StubDialect {}
262    impl FormulaRenderer for StubDialect {}
263    impl TaskRenderer for StubDialect {}
264    impl DialectCapabilities for StubDialect {}
265    impl ReplicationRenderer for StubDialect {}
266    impl StorageRenderer for StubDialect {}
267    impl PlatformDialect for StubDialect {
268        fn info(&self) -> DialectInfo {
269            self.info.clone()
270        }
271    }
272
273    fn test_registry() -> PlatformRegistry {
274        let mut reg = PlatformRegistry::new();
275        let entries: &[(&str, &str, DialectFamily, &str, bool, bool, bool)] = &[
276            ("postgres", "PostgreSQL", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
277            ("snowflake", "Snowflake", DialectFamily::Snowflake, "Snowflake", true, true, false),
278            ("sqlserver", "SQL Server", DialectFamily::TSql, "T-SQL", false, false, false),
279            ("databricks", "Databricks", DialectFamily::SparkSql, "Spark SQL", true, true, false),
280            (
281                "clickhouse",
282                "ClickHouse",
283                DialectFamily::ClickHouse,
284                "ClickHouse SQL",
285                false,
286                true,
287                false,
288            ),
289            ("duckdb", "DuckDB", DialectFamily::PostgreSql, "PostgreSQL", false, true, true),
290            ("citus", "Citus", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
291            ("mariadb", "MariaDB", DialectFamily::MariaDB, "MariaDB", false, true, false),
292            ("mysql", "MySQL", DialectFamily::MariaDB, "MariaDB", false, false, false),
293            ("agnostic", "Agnostic", DialectFamily::Razeri, "ANSI", false, false, false),
294            ("sqlite", "SQLite", DialectFamily::MariaDB, "SQLite", false, false, false),
295        ];
296        for &(name, display, family, fam_str, cloud, olap, pg_compat) in entries {
297            reg.register(
298                name.to_string(),
299                Arc::new(StubDialect {
300                    info: DialectInfo {
301                        name,
302                        display_name: display,
303                        dialect_family: family,
304                        family: fam_str,
305                        is_cloud_native: cloud,
306                        is_olap: olap,
307                        is_postgres_compatible: pg_compat,
308                    },
309                }),
310            );
311        }
312        reg
313    }
314
315    #[test]
316    fn alias_lookup_finds_canonical_names() {
317        assert_eq!(alias_lookup("pg"), Some("postgres"));
318        assert_eq!(alias_lookup("postgresql"), Some("postgres"));
319        assert_eq!(alias_lookup("tsql"), Some("sqlserver"));
320        assert_eq!(alias_lookup("mssql"), Some("sqlserver"));
321        assert_eq!(alias_lookup("sf"), Some("snowflake"));
322        assert_eq!(alias_lookup("ch"), Some("clickhouse"));
323        assert_eq!(alias_lookup("duck"), Some("duckdb"));
324        assert_eq!(alias_lookup("dbx"), Some("databricks"));
325        assert_eq!(alias_lookup("spark"), Some("databricks"));
326        assert_eq!(alias_lookup("citusdb"), Some("citus"));
327        assert_eq!(alias_lookup("sqlite"), Some("sqlite"));
328        assert_eq!(alias_lookup("agnostic"), Some("agnostic"));
329    }
330
331    #[test]
332    fn alias_lookup_with_unknown_returns_none() {
333        assert_eq!(alias_lookup("nosuchdb"), None);
334    }
335
336    #[test]
337    fn levenshtein_distance_basic() {
338        assert_eq!(levenshtein("postgres", "postgres"), 0);
339        assert_eq!(levenshtein("postgre", "postgres"), 1);
340        assert_eq!(levenshtein("postgrse", "postgres"), 2);
341        assert_eq!(levenshtein("", "abc"), 3);
342    }
343
344    #[test]
345    fn resolve_with_canonical_name_succeeds() {
346        let reg = test_registry();
347        assert!(resolve_dialect_by_name_with_registry("postgres", &reg).is_ok());
348    }
349
350    #[test]
351    fn resolve_with_alias_succeeds() {
352        let reg = test_registry();
353        assert!(resolve_dialect_by_name_with_registry("pg", &reg).is_ok());
354    }
355
356    #[test]
357    fn resolve_is_case_insensitive() {
358        let reg = test_registry();
359        assert!(resolve_dialect_by_name_with_registry("SNOWFLAKE", &reg).is_ok());
360    }
361
362    #[test]
363    fn resolve_sqlite_extension_succeeds() {
364        let reg = test_registry();
365        assert!(resolve_dialect_by_name_with_registry("sqlite", &reg).is_ok());
366    }
367
368    #[test]
369    fn resolve_databricks_succeeds() {
370        let reg = test_registry();
371        assert!(resolve_dialect_by_name_with_registry("databricks", &reg).is_ok());
372    }
373
374    #[test]
375    fn resolve_mssql_alias_succeeds() {
376        let reg = test_registry();
377        assert!(resolve_dialect_by_name_with_registry("mssql", &reg).is_ok());
378    }
379
380    #[test]
381    fn resolve_unknown_returns_error_with_suggestions() {
382        let reg = test_registry();
383        let result = resolve_dialect_by_name_with_registry("postres", &reg);
384        let Err(err) = result else { panic!("expected error") };
385        let msg = err.to_string();
386        assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
387        assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
388    }
389
390    #[test]
391    fn resolve_completely_unknown_returns_registered_list() {
392        let reg = test_registry();
393        let result = resolve_dialect_by_name_with_registry("zzzznotadb", &reg);
394        let Err(err) = result else { panic!("expected error") };
395        let msg = err.to_string();
396        assert!(msg.contains("Registered:"), "error should list registered: {msg}");
397    }
398
399    #[test]
400    fn all_accepted_names_includes_aliases_and_registered() {
401        let names = all_accepted_names();
402        assert!(names.contains(&"pg".to_string()));
403        assert!(names.contains(&"postgres".to_string()));
404        assert!(names.contains(&"snowflake".to_string()));
405    }
406
407    /// The published design schema's `DatabasePlatform` enum must speak the
408    /// SAME vocabulary the resolver accepts.
409    ///
410    /// A design's `design.platforms` / `design.default_platform` strings are
411    /// resolved — everywhere they are resolved at all — through
412    /// [`resolve_dialect_by_name`], whose left-hand [`ALIAS_TABLE`] column is
413    /// the operative accepted set. When the schema's enum is a *subset* of it,
414    /// designs that parse and render cleanly fail their own published schema;
415    /// when it is a *superset*, the schema advertises a spelling the resolver
416    /// refuses. Set equality is the only honest relation, so this test pins it
417    /// in the crate that owns the table — the schema cannot drift away from
418    /// the resolver, and the resolver cannot grow a spelling the schema never
419    /// learned.
420    ///
421    /// It is *set* equality, not ordered equality, because [`ALIAS_TABLE`]'s
422    /// own contract says its order does not matter — an alias moved between
423    /// two families is not a drift, and a test that failed on it would be
424    /// lying.
425    #[test]
426    fn design_schema_platform_enum_matches_the_alias_table() {
427        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
428            .join("../../schemas/razeri-design.schema.json");
429        let text = std::fs::read_to_string(&schema_path)
430            .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
431        let schema: serde_json::Value =
432            serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse the design schema: {e}"));
433
434        let enum_values = schema["$defs"]["DatabasePlatform"]["enum"]
435            .as_array()
436            .expect("$defs.DatabasePlatform.enum is an array in the published design schema");
437        let mut published: Vec<String> = enum_values
438            .iter()
439            .map(|v| v.as_str().expect("every DatabasePlatform enum value is a string").to_string())
440            .collect();
441        published.sort();
442        published.dedup();
443
444        let mut accepted: Vec<String> =
445            ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
446        accepted.sort();
447        accepted.dedup();
448
449        let advertised_but_refused: Vec<&String> =
450            published.iter().filter(|n| !accepted.contains(n)).collect();
451        let accepted_but_unpublished: Vec<&String> =
452            accepted.iter().filter(|n| !published.contains(n)).collect();
453
454        assert!(
455            advertised_but_refused.is_empty() && accepted_but_unpublished.is_empty(),
456            "schemas/razeri-design.schema.json $defs.DatabasePlatform has drifted from the \
457             canonical resolver's ALIAS_TABLE.\n  advertised by the schema but REFUSED by the \
458             resolver: {advertised_but_refused:?}\n  accepted by the resolver but MISSING from \
459             the schema: {accepted_but_unpublished:?}\nThe alias table is the authority: add the \
460             spelling to the schema's enum, or take it out of the alias table — do not let the \
461             two vocabularies differ."
462        );
463    }
464}