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//! Resolving a platform name to the dialect that renders it.
6//!
7//! Every service crate (CLI, API, MCP, AI, pipeline) calls
8//! [`resolve_dialect_by_name`] instead of matching on names itself.
9//!
10//! The accepted spellings are not written down here. Each dialect declares its
11//! own name and the aliases it answers to (`DialectInfo::aliases`), and this
12//! module reads them off whatever dialects are loaded — so a platform that
13//! arrives as a plugin, whether linked in or loaded from a shared library at
14//! run time, brings its spellings with it and needs no change to Razeri.
15
16use crate::PlatformDialect;
17use crate::dialect::PlatformRegistry;
18use std::sync::Arc;
19use thiserror::Error;
20
21/// Error returned when a platform name cannot be resolved to one dialect.
22#[derive(Debug, Error)]
23pub enum ResolveError {
24    /// No loaded dialect answers to the name.
25    #[error(
26        "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
27        suggestions_text = if suggestions.is_empty() {
28            String::new()
29        } else {
30            format!("Did you mean: {}? ", suggestions.join(", "))
31        },
32        registered = registered.join(", ")
33    )]
34    UnknownDialect {
35        /// The name that was looked up.
36        name: String,
37        /// Near-miss suggestions (by edit distance).
38        suggestions: Vec<String>,
39        /// All registered dialect names.
40        registered: Vec<String>,
41    },
42
43    /// More than one loaded dialect answers to the name, so it names neither.
44    ///
45    /// Two plugins claiming one spelling is a mistake in how they are named,
46    /// not something to guess at: whichever dialect happened to be found first
47    /// would silently render the other one's platform.
48    #[error(
49        "the platform name '{name}' belongs to {claimed_by_text} at once — use one platform's \
50         own name, or load only one of them",
51        claimed_by_text = claimed_by.join(" and ")
52    )]
53    AmbiguousName {
54        /// The name that was looked up.
55        name: String,
56        /// The registered dialects that each answer to it, sorted.
57        claimed_by: Vec<String>,
58    },
59}
60
61/// Resolve a platform name to the dialect that renders it.
62///
63/// Reads the loaded dialects' declared spellings — the canonical name plus each
64/// alias — ignoring case and surrounding whitespace.
65///
66/// # Errors
67///
68/// [`ResolveError::UnknownDialect`], with near-miss suggestions, if no loaded
69/// dialect answers to the name; [`ResolveError::AmbiguousName`] if two do.
70///
71/// # Examples
72///
73/// ```no_run
74/// use razeri_plugin_api::ResolveError;
75/// use razeri_plugin_api::resolve::resolve_dialect_by_name;
76///
77/// # fn main() -> Result<(), ResolveError> {
78/// let dialect = resolve_dialect_by_name("pg")?;
79/// println!("{}", dialect.info().name);
80///
81/// let dialect = resolve_dialect_by_name("SNOWFLAKE")?;
82/// # Ok(())
83/// # }
84/// ```
85pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
86    resolve_dialect_by_name_with_registry(name, crate::process::process_registry())
87}
88
89/// Resolve using an explicit registry — the path a host takes when it has built
90/// its own, including one holding dialects loaded at run time.
91///
92/// # Errors
93///
94/// The same two errors as [`resolve_dialect_by_name`].
95pub fn resolve_dialect_by_name_with_registry(
96    name: &str,
97    registry: &PlatformRegistry,
98) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
99    let wanted = name.trim();
100    let mut claimants: Vec<String> = registry
101        .registered_names()
102        .into_iter()
103        .filter(|key| answers_to(registry, key, wanted))
104        .collect();
105    claimants.sort();
106    claimants.dedup();
107
108    match claimants.as_slice() {
109        [] => {
110            let all_names = registered_names(registry);
111            let suggestions = near_misses(&wanted.to_lowercase(), &all_names);
112            Err(ResolveError::UnknownDialect {
113                name: name.to_string(),
114                suggestions,
115                registered: all_names,
116            })
117        }
118        [only] => registry.get_by_name(only).map(Arc::clone).ok_or_else(|| {
119            let all_names = registered_names(registry);
120            ResolveError::UnknownDialect {
121                name: name.to_string(),
122                suggestions: Vec::new(),
123                registered: all_names,
124            }
125        }),
126        _ => Err(ResolveError::AmbiguousName { name: wanted.to_string(), claimed_by: claimants }),
127    }
128}
129
130/// Every spelling the loaded dialects answer to, sorted.
131///
132/// Read off the dialects themselves, so a plugin's own names appear here the
133/// moment it is loaded.
134#[must_use]
135pub fn accepted_names(registry: &PlatformRegistry) -> Vec<String> {
136    let mut names: Vec<String> = registry
137        .list()
138        .iter()
139        .flat_map(|info| info.spellings().into_iter().map(str::to_owned))
140        .collect();
141    // The keys a host registered under resolve too, whatever the dialect calls
142    // itself.
143    names.extend(registry.registered_names());
144    names.sort();
145    names.dedup();
146    names
147}
148
149/// Every spelling the dialects linked into this program answer to, sorted.
150#[must_use]
151pub fn all_accepted_names() -> Vec<String> {
152    accepted_names(&PlatformRegistry::with_auto_discovered())
153}
154
155// ── Internal helpers ────────────────────────────────────────────────────
156
157/// Does the dialect registered under `key` answer to `wanted`?
158///
159/// The registration key counts alongside the dialect's own declared spellings:
160/// a host may register a dialect under a name of its choosing, and the name it
161/// chose has to resolve.
162fn answers_to(registry: &PlatformRegistry, key: &str, wanted: &str) -> bool {
163    if key.trim().eq_ignore_ascii_case(wanted) {
164        return true;
165    }
166    registry.get_by_name(key).is_some_and(|dialect| dialect.info().answers_to(wanted))
167}
168
169/// Collect all registered dialect names from the registry.
170fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
171    let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
172    names.sort();
173    names
174}
175
176/// Find near-miss suggestions using Levenshtein distance ≤ 3.
177fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
178    let mut scored: Vec<(&str, usize)> = candidates
179        .iter()
180        .filter_map(|c| {
181            let dist = levenshtein(input, c);
182            if dist <= 3 { Some((c.as_str(), dist)) } else { None }
183        })
184        .collect();
185    scored.sort_by_key(|(_, d)| *d);
186    scored.into_iter().map(|(name, _)| name.to_string()).collect()
187}
188
189/// Simple Levenshtein distance (no allocation beyond the DP row).
190fn levenshtein(a: &str, b: &str) -> usize {
191    let a_bytes = a.as_bytes();
192    let b_bytes = b.as_bytes();
193    let m = a_bytes.len();
194    let n = b_bytes.len();
195
196    let mut prev: Vec<usize> = (0..=n).collect();
197    let mut curr = vec![0; n + 1];
198
199    for i in 1..=m {
200        curr[0] = i;
201        for j in 1..=n {
202            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
203            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
204        }
205        std::mem::swap(&mut prev, &mut curr);
206    }
207    prev[n]
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
214    use razeri_plugin_contract::DataType;
215    use razeri_plugin_contract::DialectFamily;
216    use razeri_plugin_contract::{
217        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
218        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
219    };
220
221    /// Minimal test dialect.
222    struct StubDialect {
223        info: DialectInfo,
224    }
225
226    impl SqlRenderer for StubDialect {
227        fn render_data_type(&self, _dt: &DataType) -> String {
228            String::new()
229        }
230        fn quote_identifier(&self, name: &str) -> String {
231            format!("\"{name}\"")
232        }
233        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
234            self.quote_identifier(name)
235        }
236        fn render_current_timestamp(&self) -> String {
237            String::new()
238        }
239    }
240    impl ExpressionRenderer for StubDialect {}
241    impl DdlRenderer for StubDialect {}
242    impl DmlRenderer for StubDialect {}
243    impl ProcedureRenderer for StubDialect {}
244    impl FormulaRenderer for StubDialect {}
245    impl TaskRenderer for StubDialect {}
246    impl DialectCapabilities for StubDialect {}
247    impl ReplicationRenderer for StubDialect {}
248    impl StorageRenderer for StubDialect {}
249    impl PlatformDialect for StubDialect {
250        fn info(&self) -> DialectInfo {
251            self.info.clone()
252        }
253    }
254
255    /// The shipped-platform shape in miniature: a name, and the other
256    /// spellings that name answers to.
257    fn test_registry() -> PlatformRegistry {
258        let mut reg = PlatformRegistry::new();
259        let entries: &[(&str, &str, &[&str], DialectFamily, &str)] = &[
260            (
261                "postgres",
262                "PostgreSQL",
263                &["postgresql", "pg"],
264                DialectFamily::PostgreSql,
265                "PostgreSQL",
266            ),
267            ("snowflake", "Snowflake", &["sf"], DialectFamily::Snowflake, "Snowflake"),
268            (
269                "sqlserver",
270                "SQL Server",
271                &["tsql", "mssql", "sql_server"],
272                DialectFamily::TSql,
273                "T-SQL",
274            ),
275            ("databricks", "Databricks", &["dbx", "spark"], DialectFamily::SparkSql, "Spark SQL"),
276            ("clickhouse", "ClickHouse", &["ch"], DialectFamily::ClickHouse, "ClickHouse SQL"),
277            ("duckdb", "DuckDB", &["duck"], DialectFamily::PostgreSql, "PostgreSQL"),
278            ("citus", "Citus", &["citusdb"], DialectFamily::PostgreSql, "PostgreSQL"),
279            ("mariadb", "MariaDB", &["mariadb_columnstore"], DialectFamily::MariaDB, "MariaDB"),
280            ("mysql", "MySQL", &[], DialectFamily::MariaDB, "MariaDB"),
281            ("agnostic", "Agnostic", &[], DialectFamily::Razeri, "ANSI"),
282            ("sqlite", "SQLite", &[], DialectFamily::MariaDB, "SQLite"),
283        ];
284        for &(name, display, aliases, family, fam_str) in entries {
285            reg.register(name.to_string(), Arc::new(stub(name, display, aliases, family, fam_str)));
286        }
287        reg
288    }
289
290    fn stub(
291        name: &'static str,
292        display: &'static str,
293        aliases: &'static [&'static str],
294        family: DialectFamily,
295        fam_str: &'static str,
296    ) -> StubDialect {
297        StubDialect {
298            info: DialectInfo {
299                name,
300                display_name: display,
301                aliases,
302                dialect_family: family,
303                family: fam_str,
304                is_cloud_native: false,
305                is_olap: false,
306                is_postgres_compatible: false,
307            },
308        }
309    }
310
311    fn resolved(name: &str, registry: &PlatformRegistry) -> String {
312        resolve_dialect_by_name_with_registry(name, registry)
313            .unwrap_or_else(|e| panic!("'{name}' resolves: {e}"))
314            .info()
315            .name
316            .to_owned()
317    }
318
319    /// Every spelling a loaded dialect declares resolves back to that dialect.
320    ///
321    /// The resolver keeps no list of its own, so this is the whole contract:
322    /// what the dialects say is what Razeri accepts.
323    #[test]
324    fn every_declared_spelling_resolves_to_the_dialect_that_declared_it() {
325        let reg = test_registry();
326        for info in reg.list() {
327            for spelling in info.spellings() {
328                assert_eq!(resolved(spelling, &reg), info.name, "spelling '{spelling}'");
329            }
330        }
331    }
332
333    #[test]
334    fn the_declared_spellings_are_the_accepted_names_and_nothing_else_is() {
335        let reg = test_registry();
336        let accepted = accepted_names(&reg);
337        for info in reg.list() {
338            for spelling in info.spellings() {
339                assert!(accepted.contains(&spelling.to_owned()), "'{spelling}' is accepted");
340            }
341        }
342        for name in accepted {
343            assert!(
344                reg.list().iter().any(|info| info.spellings().contains(&name.as_str())),
345                "'{name}' is accepted, so some loaded dialect declares it"
346            );
347        }
348    }
349
350    #[test]
351    fn resolution_ignores_case_and_surrounding_whitespace() {
352        let reg = test_registry();
353        assert_eq!(resolved("SNOWFLAKE", &reg), "snowflake");
354        assert_eq!(resolved("  DuckDB  ", &reg), "duckdb");
355        assert_eq!(resolved("PG", &reg), "postgres");
356    }
357
358    #[test]
359    fn a_name_the_host_registered_resolves_even_if_the_dialect_never_declared_it() {
360        let mut reg = PlatformRegistry::new();
361        reg.register(
362            "housename".to_string(),
363            Arc::new(stub("mydb", "MyDB", &[], DialectFamily::Custom("mydb"), "MyDB")),
364        );
365        assert_eq!(resolved("housename", &reg), "mydb");
366        assert_eq!(resolved("mydb", &reg), "mydb");
367    }
368
369    /// **A platform Razeri has never heard of resolves by the names it brings
370    /// with it.** Nothing in this crate mentions `outsidedb` or `odb` — the
371    /// dialect declares them and the resolver reads them off the registry, which
372    /// is what makes a dialect loaded at run time as usable as a built-in one.
373    #[test]
374    fn a_dialect_razeri_never_heard_of_resolves_by_its_own_spellings() {
375        let mut reg = test_registry();
376        reg.register(
377            "outsidedb".to_string(),
378            Arc::new(stub(
379                "outsidedb",
380                "Outside DB",
381                &["odb", "outside_db"],
382                DialectFamily::Custom("outsidedb"),
383                "OutsideDB",
384            )),
385        );
386        for spelling in ["outsidedb", "odb", "outside_db", "  ODB "] {
387            assert_eq!(resolved(spelling, &reg), "outsidedb", "spelling '{spelling}'");
388        }
389        // It renders through the same trait objects as any built-in.
390        let dialect = resolve_dialect_by_name_with_registry("odb", &reg).expect("resolves");
391        assert_eq!(dialect.quote_identifier("orders"), "\"orders\"");
392        assert!(accepted_names(&reg).contains(&"odb".to_string()));
393    }
394
395    #[test]
396    fn resolve_unknown_returns_error_with_suggestions() {
397        let reg = test_registry();
398        let result = resolve_dialect_by_name_with_registry("postres", &reg);
399        let Err(err) = result else { panic!("expected error") };
400        let msg = err.to_string();
401        assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
402        assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
403    }
404
405    #[test]
406    fn resolve_completely_unknown_returns_registered_list() {
407        let reg = test_registry();
408        let result = resolve_dialect_by_name_with_registry("zzzznotadb", &reg);
409        let Err(err) = result else { panic!("expected error") };
410        let msg = err.to_string();
411        assert!(msg.contains("Registered:"), "error should list registered: {msg}");
412    }
413
414    /// **A spelling two platforms claim names neither, and saying so is the
415    /// point.** Handing back whichever was found first would render one
416    /// platform's SQL under the other's name.
417    #[test]
418    fn a_spelling_two_dialects_claim_is_refused_and_both_are_named() {
419        let mut reg = test_registry();
420        reg.register(
421            "sparkler".to_string(),
422            Arc::new(stub(
423                "sparkler",
424                "Sparkler",
425                &["spark"],
426                DialectFamily::Custom("sparkler"),
427                "Sparkler",
428            )),
429        );
430        let Err(err) = resolve_dialect_by_name_with_registry("spark", &reg) else {
431            panic!("a spelling two dialects claim must not resolve")
432        };
433        let msg = err.to_string();
434        assert!(msg.contains("databricks"), "names the first claimant: {msg}");
435        assert!(msg.contains("sparkler"), "names the second claimant: {msg}");
436        assert!(msg.contains("use one platform's own name"), "says what to do: {msg}");
437        // The unshared spellings still resolve — one shared alias does not take
438        // either platform down with it.
439        assert_eq!(resolved("databricks", &reg), "databricks");
440        assert_eq!(resolved("dbx", &reg), "databricks");
441        assert_eq!(resolved("sparkler", &reg), "sparkler");
442    }
443
444    #[test]
445    fn a_dialect_claiming_another_dialects_own_name_is_refused_too() {
446        let mut reg = test_registry();
447        reg.register(
448            "impostor".to_string(),
449            Arc::new(stub(
450                "impostor",
451                "Impostor",
452                &["duckdb"],
453                DialectFamily::Custom("impostor"),
454                "Impostor",
455            )),
456        );
457        let Err(err) = resolve_dialect_by_name_with_registry("duckdb", &reg) else {
458            panic!("a claimed name must not resolve to whichever came first")
459        };
460        assert!(err.to_string().contains("impostor"), "{err}");
461    }
462
463    #[test]
464    fn levenshtein_distance_basic() {
465        assert_eq!(levenshtein("postgres", "postgres"), 0);
466        assert_eq!(levenshtein("postgre", "postgres"), 1);
467        assert_eq!(levenshtein("postgrse", "postgres"), 2);
468        assert_eq!(levenshtein("", "abc"), 3);
469    }
470}