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