dictator_core/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3pub mod config;
4pub mod linter_output;
5
6use anyhow::Result;
7use camino::Utf8Path;
8use dictator_decree_abi::{BoxDecree, Diagnostics};
9use std::collections::HashSet;
10
11pub use config::{DecreeSettings, DictateConfig};
12
13/// In-memory source file for the Regime to enforce.
14pub struct Source<'a> {
15    pub path: &'a Utf8Path,
16    pub text: &'a str,
17}
18
19/// The Regime: owns decree instances and enforces them over sources.
20pub struct Regime {
21    decrees: Vec<BoxDecree>,
22}
23
24impl Default for Regime {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl Regime {
31    #[must_use]
32    pub fn new() -> Self {
33        Self {
34            decrees: Vec::new(),
35        }
36    }
37
38    #[must_use]
39    pub fn with_decree(mut self, decree: BoxDecree) -> Self {
40        self.decrees.push(decree);
41        self
42    }
43
44    pub fn add_decree(&mut self, decree: BoxDecree) {
45        self.decrees.push(decree);
46    }
47
48    /// Return the union of supported extensions for all loaded decrees.
49    ///
50    /// - If at least one decree declares specific extensions, returns `Some(HashSet)` of
51    ///   those (lowercased) extensions.
52    /// - If no decree declares extensions (all empty lists), returns `None`, meaning
53    ///   "watch everything" (typical when only supreme is loaded).
54    #[must_use]
55    pub fn watched_extensions(&self) -> Option<HashSet<String>> {
56        let mut exts = HashSet::new();
57        for decree in &self.decrees {
58            let supported = &decree.metadata().supported_extensions;
59            if supported.is_empty() {
60                continue; // empty means "all" for enforcement, but we don't widen the watch set
61            }
62            for ext in supported {
63                exts.insert(ext.to_ascii_lowercase());
64            }
65        }
66
67        if exts.is_empty() { None } else { Some(exts) }
68    }
69
70    /// Load a WASM decree from a file path.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the file cannot be loaded, if it's not a valid WASM/native decree,
75    /// or if the decree's ABI version is incompatible with the host.
76    pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<()> {
77        let decree = loader::load_decree(path.as_ref())?;
78        self.decrees.push(decree);
79        Ok(())
80    }
81
82    /// Enforce all decrees over provided sources.
83    ///
84    /// Only runs a decree on files whose extension matches the decree's
85    /// `supported_extensions`. Decrees with empty `supported_extensions`
86    /// (like decree.supreme) run on all files.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if any decree fails during linting.
91    pub fn enforce(&self, sources: &[Source<'_>]) -> Result<Diagnostics> {
92        let mut all = Diagnostics::new();
93        for decree in &self.decrees {
94            let supported = &decree.metadata().supported_extensions;
95            for src in sources {
96                // Empty supported_extensions means "all files" (e.g., decree.supreme)
97                if supported.is_empty() || Self::extension_matches(src.path, supported) {
98                    all.extend(decree.lint(src.path.as_str(), src.text));
99                }
100            }
101        }
102        Ok(all)
103    }
104
105    /// Check if a file's extension matches any in the supported list.
106    fn extension_matches(path: &Utf8Path, supported: &[String]) -> bool {
107        path.extension()
108            .is_some_and(|ext| supported.iter().any(|s| s == ext))
109    }
110}
111
112mod loader {
113    use anyhow::{Context, Result};
114    use dictator_decree_abi::{BoxDecree, Diagnostics, Span};
115    use libloading::Library;
116    use std::path::Path;
117    use std::sync::Mutex;
118    use wasmtime::component::{Component, Linker, ResourceTable};
119    use wasmtime::{Config, Engine, Store};
120    use wasmtime_wasi::p2::add_to_linker_sync;
121    use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
122
123    mod bindings {
124        wasmtime::component::bindgen!({ path: "wit/decree.wit", world: "decree" });
125    }
126
127    /// Load a decree compiled as a native dynamic library (.dylib/.so/.dll).
128    ///
129    /// # Safety
130    /// Loading dynamic libraries is inherently unsafe. The library must:
131    /// - Export a valid `dictator_create_decree` symbol
132    /// - Return a valid boxed Decree
133    /// - Not cause undefined behavior when called
134    #[allow(unsafe_code)]
135    fn load_native(lib_path: &Path) -> Result<BoxDecree> {
136        use dictator_decree_abi::{ABI_VERSION, DECREE_FACTORY_EXPORT, DecreeFactory};
137
138        // We must keep the library handle alive for the lifetime of the process; unloading
139        // invalidates function pointers held by the decree and triggers UB. We keep every
140        // successfully loaded Library in a global registry instead of letting it drop.
141        static LOADED_LIBRARIES: std::sync::OnceLock<std::sync::Mutex<Vec<Library>>> =
142            std::sync::OnceLock::new();
143
144        unsafe {
145            let lib = Library::new(lib_path)
146                .with_context(|| format!("failed to load native decree: {}", lib_path.display()))?;
147            let ctor: libloading::Symbol<DecreeFactory> =
148                lib.get(DECREE_FACTORY_EXPORT.as_bytes()).with_context(|| {
149                    format!(
150                        "missing symbol {} in {}",
151                        DECREE_FACTORY_EXPORT,
152                        lib_path.display()
153                    )
154                })?;
155
156            let decree = ctor();
157
158            // Validate ABI compatibility
159            let metadata = decree.metadata();
160            metadata.validate_abi(ABI_VERSION).map_err(|e| {
161                anyhow::anyhow!(
162                    "Decree '{}' from {}: {}",
163                    decree.name(),
164                    lib_path.display(),
165                    e
166                )
167            })?;
168
169            tracing::info!(
170                "Loaded decree '{}' v{} (ABI {})",
171                decree.name(),
172                metadata.decree_version,
173                metadata.abi_version
174            );
175
176            // Park the library handle so it is never dropped/unloaded.
177            LOADED_LIBRARIES
178                .get_or_init(std::sync::Mutex::default)
179                .lock()
180                .expect("loaded libraries mutex poisoned")
181                .push(lib);
182
183            Ok(decree)
184        }
185    }
186
187    use self::bindings::exports::dictator::decree::lints as guest;
188
189    struct HostState {
190        table: ResourceTable,
191        wasi: WasiCtx,
192    }
193
194    impl WasiView for HostState {
195        fn ctx(&mut self) -> WasiCtxView<'_> {
196            WasiCtxView {
197                ctx: &mut self.wasi,
198                table: &mut self.table,
199            }
200        }
201    }
202
203    struct WasmDecree {
204        name: String,
205        metadata: dictator_decree_abi::DecreeMetadata,
206        state: Mutex<WasmState>,
207    }
208
209    struct WasmState {
210        store: Store<HostState>,
211        plugin: bindings::Decree,
212    }
213
214    impl dictator_decree_abi::Decree for WasmDecree {
215        fn name(&self) -> &str {
216            &self.name
217        }
218
219        #[allow(clippy::significant_drop_tightening)]
220        fn lint(&self, path: &str, source: &str) -> Diagnostics {
221            let result = {
222                let mut guard = self.state.lock().expect("wasm store poisoned");
223                let WasmState { plugin, store } = &mut *guard;
224                plugin
225                    .dictator_decree_lints()
226                    .call_lint(store, path, source)
227                    .unwrap_or_default()
228            };
229            result
230                .into_iter()
231                .map(|d| dictator_decree_abi::Diagnostic {
232                    rule: d.rule,
233                    message: d.message,
234                    enforced: matches!(d.severity, guest::Severity::Info), // Info = auto-fixed
235                    span: Span {
236                        start: d.span.start as usize,
237                        end: d.span.end as usize,
238                    },
239                })
240                .collect()
241        }
242
243        fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
244            self.metadata.clone()
245        }
246    }
247
248    fn load_wasm(lib_path: &Path) -> Result<BoxDecree> {
249        use dictator_decree_abi::ABI_VERSION;
250
251        let mut config = Config::new();
252        config.wasm_component_model(true);
253        let engine = Engine::new(&config)?;
254        let component = Component::from_file(&engine, lib_path)
255            .with_context(|| format!("failed to load wasm decree: {}", lib_path.display()))?;
256        let mut linker: Linker<HostState> = Linker::new(&engine);
257        add_to_linker_sync(&mut linker)?;
258        let host_state = HostState {
259            table: ResourceTable::new(),
260            wasi: WasiCtxBuilder::new().inherit_stdio().build(),
261        };
262        let mut store = Store::new(&engine, host_state);
263        let plugin = bindings::Decree::instantiate(&mut store, &component, &linker)?;
264        let guest = plugin.dictator_decree_lints();
265
266        let name = guest
267            .call_name(&mut store)
268            .unwrap_or_else(|_| "wasm-decree".to_string());
269
270        // Get and validate metadata
271        let wasm_meta = guest
272            .call_metadata(&mut store)
273            .context("failed to call metadata on wasm decree")?;
274
275        let metadata = dictator_decree_abi::DecreeMetadata {
276            abi_version: wasm_meta.abi_version,
277            decree_version: wasm_meta.decree_version,
278            description: wasm_meta.description,
279            dectauthors: wasm_meta.dectauthors,
280            supported_extensions: wasm_meta.supported_extensions,
281            capabilities: wasm_meta
282                .capabilities
283                .into_iter()
284                .map(|c| match c {
285                    guest::Capability::Lint => dictator_decree_abi::Capability::Lint,
286                    guest::Capability::AutoFix => dictator_decree_abi::Capability::AutoFix,
287                    guest::Capability::Streaming => dictator_decree_abi::Capability::Streaming,
288                    guest::Capability::RuntimeConfig => {
289                        dictator_decree_abi::Capability::RuntimeConfig
290                    }
291                    guest::Capability::RichDiagnostics => {
292                        dictator_decree_abi::Capability::RichDiagnostics
293                    }
294                })
295                .collect(),
296        };
297
298        metadata
299            .validate_abi(ABI_VERSION)
300            .map_err(|e| anyhow::anyhow!("Decree '{}' from {}: {}", name, lib_path.display(), e))?;
301
302        tracing::info!(
303            "Loaded WASM decree '{}' v{} (ABI {})",
304            name,
305            metadata.decree_version,
306            metadata.abi_version
307        );
308
309        Ok(Box::new(WasmDecree {
310            name,
311            metadata,
312            state: Mutex::new(WasmState { store, plugin }),
313        }))
314    }
315
316    pub fn load_decree(path: &Path) -> Result<BoxDecree> {
317        match path.extension().and_then(|s| s.to_str()) {
318            Some("wasm") => load_wasm(path),
319            _ => load_native(path),
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use dictator_decree_abi::{Capability, Decree, DecreeMetadata, Diagnostics};
328
329    struct MockDecree {
330        name: &'static str,
331        exts: Vec<String>,
332    }
333
334    impl Decree for MockDecree {
335        fn name(&self) -> &str {
336            self.name
337        }
338
339        fn lint(&self, _path: &str, _source: &str) -> Diagnostics {
340            Diagnostics::new()
341        }
342
343        fn metadata(&self) -> DecreeMetadata {
344            DecreeMetadata {
345                abi_version: "1".into(),
346                decree_version: "1".into(),
347                description: String::new(),
348                dectauthors: None,
349                supported_extensions: self.exts.clone(),
350                capabilities: vec![Capability::Lint],
351            }
352        }
353    }
354
355    #[test]
356    fn watched_extensions_unites_declared_sets() {
357        let decree_a: BoxDecree = Box::new(MockDecree {
358            name: "a",
359            exts: vec!["rs".into(), "Rb".into()],
360        });
361        let decree_b: BoxDecree = Box::new(MockDecree {
362            name: "b",
363            exts: vec!["ts".into()],
364        });
365        let mut regime = Regime::new();
366        regime.add_decree(decree_a);
367        regime.add_decree(decree_b);
368
369        let exts = regime.watched_extensions().unwrap();
370        assert!(exts.contains("rs"));
371        assert!(exts.contains("rb"));
372        assert!(exts.contains("ts"));
373        assert_eq!(exts.len(), 3);
374    }
375
376    #[test]
377    fn watched_extensions_none_when_only_universal() {
378        let sup: BoxDecree = Box::new(MockDecree {
379            name: "supreme",
380            exts: vec![], // universal decree declares no extensions
381        });
382        let mut regime = Regime::new();
383        regime.add_decree(sup);
384
385        assert!(regime.watched_extensions().is_none());
386    }
387}