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
13pub struct Source<'a> {
15 pub path: &'a Utf8Path,
16 pub text: &'a str,
17}
18
19pub 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 #[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; }
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 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 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 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 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 #[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 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 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 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), 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 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![], });
382 let mut regime = Regime::new();
383 regime.add_decree(sup);
384
385 assert!(regime.watched_extensions().is_none());
386 }
387}