Skip to main content

perspt_coding/
registry.rs

1//! Open language-adapter registry (PSP-9 system 5).
2//!
3//! Replaces closed `CodingLanguage`-enum dispatch with a `LanguageId`-keyed
4//! registry, so adding a language means *registering* an adapter — never
5//! editing the orchestrator. The runtime joins this registry with
6//! `perspt-core`'s plugin registry by id: the adapter parses diagnostics and
7//! never shells out; the plugin knows commands and availability and never
8//! parses diagnostics.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::lang::{LanguageAdapter, PythonAdapter, RustAdapter, TypeScriptAdapter};
15
16/// Open, stable language identifier (lowercase by convention).
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18pub struct LanguageId(pub String);
19
20impl LanguageId {
21    pub fn new(id: impl Into<String>) -> Self {
22        Self(id.into().to_ascii_lowercase())
23    }
24}
25
26impl std::fmt::Display for LanguageId {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32/// The adapter registry: one diagnostic-normalization adapter per language.
33pub struct CodingAdapterRegistry {
34    adapters: BTreeMap<LanguageId, Box<dyn LanguageAdapter>>,
35}
36
37impl std::fmt::Debug for CodingAdapterRegistry {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("CodingAdapterRegistry")
40            .field("languages", &self.language_ids())
41            .finish()
42    }
43}
44
45impl CodingAdapterRegistry {
46    /// An empty registry.
47    pub fn empty() -> Self {
48        Self {
49            adapters: BTreeMap::new(),
50        }
51    }
52
53    /// The registry with the first-party adapters registered.
54    pub fn with_builtins() -> Self {
55        let mut registry = Self::empty();
56        let builtins: [Box<dyn LanguageAdapter>; 3] = [
57            Box::new(RustAdapter),
58            Box::new(PythonAdapter),
59            Box::new(TypeScriptAdapter),
60        ];
61        for adapter in builtins {
62            registry.register(adapter.id(), adapter);
63        }
64        registry
65    }
66
67    /// Register (or replace) the adapter for a language.
68    pub fn register(&mut self, id: LanguageId, adapter: Box<dyn LanguageAdapter>) {
69        self.adapters.insert(id, adapter);
70    }
71
72    /// Resolve an adapter by id.
73    pub fn get(&self, id: &LanguageId) -> Option<&dyn LanguageAdapter> {
74        self.adapters.get(id).map(|a| a.as_ref())
75    }
76
77    /// Registered ids, sorted.
78    pub fn language_ids(&self) -> Vec<LanguageId> {
79        self.adapters.keys().cloned().collect()
80    }
81}
82
83impl Default for CodingAdapterRegistry {
84    fn default() -> Self {
85        Self::with_builtins()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use perspt_sdk::{CorrectionDirection, IndependenceRoute, ResidualEvent, SensorRef};
93
94    /// A fixture adapter for a language the enum has never heard of. Its
95    /// existence in this test is the Phase 2 exit criterion: registering a
96    /// language edits no orchestrator code.
97    struct GleamAdapter;
98
99    impl LanguageAdapter for GleamAdapter {
100        fn id(&self) -> LanguageId {
101            LanguageId::new("gleam")
102        }
103        fn diagnostic_sensor(&self) -> SensorRef {
104            SensorRef::new("gleam-check", IndependenceRoute::DeterministicTool)
105        }
106        fn parse_diagnostics(&self, _n: &str, _g: u32, _raw: &str) -> Vec<ResidualEvent> {
107            Vec::new()
108        }
109        fn correction_for(&self, _r: &ResidualEvent) -> Option<CorrectionDirection> {
110            None
111        }
112    }
113
114    #[test]
115    fn builtins_resolve_by_id() {
116        let registry = CodingAdapterRegistry::with_builtins();
117        for id in ["rust", "python", "typescript"] {
118            assert!(registry.get(&LanguageId::new(id)).is_some(), "{id}");
119        }
120        assert!(registry.get(&LanguageId::new("cobol")).is_none());
121    }
122
123    #[test]
124    fn a_new_language_registers_without_editing_the_orchestrator() {
125        let mut registry = CodingAdapterRegistry::with_builtins();
126        registry.register(LanguageId::new("gleam"), Box::new(GleamAdapter));
127        assert!(registry.get(&LanguageId::new("gleam")).is_some());
128        assert_eq!(registry.language_ids().len(), 4);
129    }
130
131    #[test]
132    fn ids_are_case_insensitive_by_construction() {
133        assert_eq!(LanguageId::new("Rust"), LanguageId::new("rust"));
134    }
135}