#![warn(rust_2024_compatibility, clippy::all)]
pub mod config;
mod decree_matching;
pub mod error;
pub mod linter_output;
mod rule_ignoring;
pub mod wasm_cache;
use anyhow::Result;
use camino::Utf8Path;
use dictator_decree_abi::{BoxDecree, Diagnostics};
use std::collections::HashSet;
pub use config::{DecreeSettings, DictateConfig};
pub use error::{DictatorContext, DictatorError, suggestions};
pub use wasm_cache::WasmCacheStats;
pub struct Source<'a> {
pub path: &'a Utf8Path,
pub text: &'a str,
}
pub struct Regime {
decrees: Vec<BoxDecree>,
rule_ignores: rule_ignoring::RuleIgnores,
}
impl Default for Regime {
fn default() -> Self {
Self::new()
}
}
impl Regime {
#[must_use]
pub fn new() -> Self {
Self {
decrees: Vec::new(),
rule_ignores: rule_ignoring::RuleIgnores::new(),
}
}
#[must_use]
pub fn wasm_cache_stats(&self) -> Option<WasmCacheStats> {
#[cfg(feature = "wasm-loader")]
{
use loader::get_wasm_engine_cache;
let (_, cache) = get_wasm_engine_cache();
Some(cache.stats())
}
#[cfg(not(feature = "wasm-loader"))]
{
let _ = self; None
}
}
#[must_use]
pub fn with_decree(mut self, decree: BoxDecree) -> Self {
self.decrees.push(decree);
self
}
pub fn add_decree(&mut self, decree: BoxDecree) {
self.decrees.push(decree);
}
pub fn set_rule_ignores_from_config(&mut self, config: Option<&DictateConfig>) {
self.rule_ignores = rule_ignoring::build_rule_ignores(config);
}
#[must_use]
pub fn watched_extensions(&self) -> Option<HashSet<String>> {
let mut exts = HashSet::new();
for decree in &self.decrees {
let supported = &decree.metadata().supported_extensions;
if supported.is_empty() {
continue; }
for ext in supported {
exts.insert(ext.to_ascii_lowercase());
}
}
if exts.is_empty() { None } else { Some(exts) }
}
#[cfg(feature = "wasm-loader")]
pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<()> {
let decree = loader::load_decree(path.as_ref())?;
self.decrees.push(decree);
Ok(())
}
#[cfg(not(feature = "wasm-loader"))]
pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, _path: P) -> Result<()> {
anyhow::bail!("WASM loader disabled; enable the `wasm-loader` feature to load decrees");
}
pub fn enforce(&self, sources: &[Source<'_>]) -> Result<Diagnostics> {
let mut all = Diagnostics::new();
for src in sources {
let filename = src.path.file_name().unwrap_or("");
let is_supreme_shadowed = decree_matching::is_supreme_shadowed(&self.decrees, src.path);
for decree in &self.decrees {
let meta = decree.metadata();
if meta.skip_filenames.iter().any(|s| s == filename) {
continue;
}
let matches = decree_matching::decree_matches(src.path, &meta);
if !matches {
continue;
}
let is_universal =
meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty();
if is_supreme_shadowed && is_universal && decree.name() == "supreme" {
continue;
}
let diags = decree.lint(src.path.as_str(), src.text);
for diag in diags {
let ignores = &self.rule_ignores;
if rule_ignoring::is_rule_ignored_for_path(ignores, src.path, &diag) {
continue;
}
all.push(diag);
}
}
}
Ok(all)
}
}
#[cfg(feature = "wasm-loader")]
pub(crate) mod loader {
use crate::wasm_cache::WasmCache;
use anyhow::{Context, Result};
use dictator_decree_abi::{BoxDecree, Diagnostics, Span};
use libloading::Library;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use wasmtime::component::{Linker, ResourceTable};
use wasmtime::{Engine, Store};
use wasmtime_wasi::p2::add_to_linker_sync;
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
mod bindings {
wasmtime::component::bindgen!({ path: "wit/decree.wit", world: "decree" });
}
static WASM_ENGINE_CACHE: OnceLock<(Engine, Arc<WasmCache>)> = OnceLock::new();
pub fn get_wasm_engine_cache() -> (Engine, Arc<WasmCache>) {
WASM_ENGINE_CACHE
.get_or_init(|| {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config).expect("Failed to create WASM engine");
let cache = Arc::new(WasmCache::new().expect("Failed to create WASM cache"));
(engine, cache)
})
.clone()
}
#[allow(unsafe_code)]
fn load_native(lib_path: &Path) -> Result<BoxDecree> {
use dictator_decree_abi::{ABI_VERSION, DECREE_FACTORY_EXPORT, DecreeFactory};
static LOADED_LIBRARIES: std::sync::OnceLock<std::sync::Mutex<Vec<Library>>> =
std::sync::OnceLock::new();
unsafe {
let lib = Library::new(lib_path)
.with_context(|| format!("failed to load native decree: {}", lib_path.display()))?;
let ctor: libloading::Symbol<DecreeFactory> =
lib.get(DECREE_FACTORY_EXPORT.as_bytes()).with_context(|| {
format!(
"missing symbol {} in {}",
DECREE_FACTORY_EXPORT,
lib_path.display()
)
})?;
let decree = ctor();
let metadata = decree.metadata();
metadata.validate_abi(ABI_VERSION).map_err(|e| {
anyhow::anyhow!(
"Decree '{}' from {}: {}",
decree.name(),
lib_path.display(),
e
)
})?;
tracing::info!(
"Loaded decree '{}' v{} (ABI {})",
decree.name(),
metadata.decree_version,
metadata.abi_version
);
LOADED_LIBRARIES
.get_or_init(std::sync::Mutex::default)
.lock()
.expect("loaded libraries mutex poisoned")
.push(lib);
Ok(decree)
}
}
use self::bindings::exports::dictator::decree::lints as guest;
struct HostState {
table: ResourceTable,
wasi: WasiCtx,
}
impl WasiView for HostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
struct WasmDecree {
name: String,
metadata: dictator_decree_abi::DecreeMetadata,
state: Mutex<WasmState>,
}
struct WasmState {
store: Store<HostState>,
plugin: bindings::Decree,
}
impl dictator_decree_abi::Decree for WasmDecree {
fn name(&self) -> &str {
&self.name
}
#[allow(clippy::significant_drop_tightening)]
fn lint(&self, path: &str, source: &str) -> Diagnostics {
let result = {
let mut guard = self.state.lock().expect("wasm store poisoned");
let WasmState { plugin, store } = &mut *guard;
plugin
.dictator_decree_lints()
.call_lint(store, path, source)
.unwrap_or_default()
};
result
.into_iter()
.map(|d| dictator_decree_abi::Diagnostic {
rule: d.rule,
message: d.message,
enforced: matches!(d.severity, guest::Severity::Info), span: Span {
start: d.span.start as usize,
end: d.span.end as usize,
},
})
.collect()
}
fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
self.metadata.clone()
}
}
fn load_wasm(lib_path: &Path) -> Result<BoxDecree> {
use dictator_decree_abi::ABI_VERSION;
let (engine, cache) = get_wasm_engine_cache();
let component = cache.get_or_load(lib_path).with_context(|| {
format!("failed to load cached wasm decree: {}", lib_path.display())
})?;
let mut linker: Linker<HostState> = Linker::new(&engine);
add_to_linker_sync(&mut linker)?;
let host_state = HostState {
table: ResourceTable::new(),
wasi: WasiCtxBuilder::new().inherit_stdio().build(),
};
let mut store = Store::new(&engine, host_state);
let plugin = bindings::Decree::instantiate(&mut store, &component, &linker)?;
let guest = plugin.dictator_decree_lints();
let name = guest
.call_name(&mut store)
.unwrap_or_else(|_| "wasm-decree".to_string());
let wasm_meta = guest
.call_metadata(&mut store)
.context("failed to call metadata on wasm decree")?;
let metadata = dictator_decree_abi::DecreeMetadata {
abi_version: wasm_meta.abi_version,
decree_version: wasm_meta.decree_version,
description: wasm_meta.description,
dectauthors: wasm_meta.dectauthors,
supported_extensions: wasm_meta.supported_extensions,
supported_filenames: wasm_meta.supported_filenames,
skip_filenames: wasm_meta.skip_filenames,
capabilities: wasm_meta
.capabilities
.into_iter()
.map(|c| match c {
guest::Capability::Lint => dictator_decree_abi::Capability::Lint,
guest::Capability::AutoFix => dictator_decree_abi::Capability::AutoFix,
guest::Capability::Streaming => dictator_decree_abi::Capability::Streaming,
guest::Capability::RuntimeConfig => {
dictator_decree_abi::Capability::RuntimeConfig
}
guest::Capability::RichDiagnostics => {
dictator_decree_abi::Capability::RichDiagnostics
}
})
.collect(),
};
metadata.validate_abi(ABI_VERSION).map_err(|_e| {
let path = lib_path.to_path_buf();
crate::DictatorError::WasmLoadError {
path,
abi_version: metadata.abi_version.clone(),
expected: ABI_VERSION.to_string(),
suggestion: crate::suggestions::wasm_suggestions("abi_mismatch").to_string(),
}
})?;
tracing::info!(
"Loaded WASM decree '{}' v{} (ABI {})",
name,
metadata.decree_version,
metadata.abi_version
);
Ok(Box::new(WasmDecree {
name,
metadata,
state: Mutex::new(WasmState { store, plugin }),
}))
}
pub fn load_decree(path: &Path) -> Result<BoxDecree> {
match path.extension().and_then(|s| s.to_str()) {
Some("wasm") => load_wasm(path),
_ => load_native(path),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dictator_decree_abi::{Capability, Decree, DecreeMetadata, Diagnostics};
use dictator_decree_abi::{Diagnostic, Span};
struct MockDecree {
name: &'static str,
exts: Vec<String>,
filenames: Vec<String>,
skip: Vec<String>,
rule: &'static str,
}
impl MockDecree {
fn simple(name: &'static str, exts: Vec<String>, rule: &'static str) -> Self {
Self {
name,
exts,
filenames: vec![],
skip: vec![],
rule,
}
}
}
impl Decree for MockDecree {
fn name(&self) -> &str {
self.name
}
fn lint(&self, _path: &str, _source: &str) -> Diagnostics {
vec![Diagnostic {
rule: self.rule.to_string(),
message: format!("hit {}", self.name),
span: Span::new(0, 0),
enforced: false,
}]
}
fn metadata(&self) -> DecreeMetadata {
DecreeMetadata {
abi_version: "1".into(),
decree_version: "1".into(),
description: String::new(),
dectauthors: None,
supported_extensions: self.exts.clone(),
supported_filenames: self.filenames.clone(),
skip_filenames: self.skip.clone(),
capabilities: vec![Capability::Lint],
}
}
}
#[test]
fn watched_extensions_unites_declared_sets() {
let decree_a: BoxDecree = Box::new(MockDecree::simple(
"a",
vec!["rs".into(), "Rb".into()],
"a/hit",
));
let decree_b: BoxDecree = Box::new(MockDecree::simple("b", vec!["ts".into()], "b/hit"));
let mut regime = Regime::new();
regime.add_decree(decree_a);
regime.add_decree(decree_b);
let exts = regime.watched_extensions().unwrap();
assert!(exts.contains("rs"));
assert!(exts.contains("rb"));
assert!(exts.contains("ts"));
assert_eq!(exts.len(), 3);
}
#[test]
fn watched_extensions_none_when_only_universal() {
let sup: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let mut regime = Regime::new();
regime.add_decree(sup);
assert!(regime.watched_extensions().is_none());
}
#[test]
fn enforce_skips_supreme_when_language_specific_matches() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(ruby);
let path = Utf8Path::new("test.rb");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.iter().any(|d| d.rule == "ruby/hit"));
assert!(!diags.iter().any(|d| d.rule == "supreme/hit"));
}
#[test]
fn enforce_runs_supreme_when_language_specific_does_not_match() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(ruby);
let path = Utf8Path::new("test.txt");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
assert!(!diags.iter().any(|d| d.rule == "ruby/hit"));
}
#[test]
fn enforce_ignores_configured_rules_by_filename() {
let supreme: BoxDecree = Box::new(MockDecree::simple(
"supreme",
vec![],
"supreme/tab-character",
));
let mut settings = DecreeSettings::default();
settings.ignore.insert(
"tab-character".to_string(),
crate::config::RuleIgnore {
filenames: vec!["Makefile".to_string()],
extensions: vec![],
},
);
let mut config = DictateConfig::default();
config.decree.insert("supreme".to_string(), settings);
let mut regime = Regime::new();
regime.set_rule_ignores_from_config(Some(&config));
regime.add_decree(supreme);
let path = Utf8Path::new("Makefile");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.is_empty(), "rule should be ignored for Makefile");
}
#[test]
fn enforce_ignores_configured_rules_by_extension() {
let supreme: BoxDecree = Box::new(MockDecree::simple(
"supreme",
vec![],
"supreme/tab-character",
));
let mut settings = DecreeSettings::default();
settings.ignore.insert(
"tab-character".to_string(),
crate::config::RuleIgnore {
filenames: vec![],
extensions: vec!["md".to_string(), "MDX".to_string()],
},
);
let mut config = DictateConfig::default();
config.decree.insert("supreme".to_string(), settings);
let mut regime = Regime::new();
regime.set_rule_ignores_from_config(Some(&config));
regime.add_decree(supreme);
let path = Utf8Path::new("README.md");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.is_empty(), "rule should be ignored for .md");
let path = Utf8Path::new("doc.mdx");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.is_empty(), "rule should be ignored for .mdx");
}
#[test]
fn enforce_does_not_ignore_unconfigured_rules() {
let supreme: BoxDecree = Box::new(MockDecree::simple(
"supreme",
vec![],
"supreme/trailing-whitespace",
));
let mut settings = DecreeSettings::default();
settings.ignore.insert(
"tab-character".to_string(),
crate::config::RuleIgnore {
filenames: vec!["Makefile".to_string()],
extensions: vec!["md".to_string()],
},
);
let mut config = DictateConfig::default();
config.decree.insert("supreme".to_string(), settings);
let mut regime = Regime::new();
regime.set_rule_ignores_from_config(Some(&config));
regime.add_decree(supreme);
let path = Utf8Path::new("README.md");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags
.iter()
.any(|d| d.rule == "supreme/trailing-whitespace"),
"unconfigured rules should still be reported"
);
}
#[test]
fn enforce_does_not_shadow_supreme_for_non_language_decree() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let frontmatter: BoxDecree = Box::new(MockDecree::simple(
"frontmatter",
vec!["md".into()],
"frontmatter/hit",
));
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(frontmatter);
let path = Utf8Path::new("README.md");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
assert!(diags.iter().any(|d| d.rule == "frontmatter/hit"));
}
#[test]
fn enforce_golang_shadows_supreme_for_go_files() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let golang: BoxDecree = Box::new(MockDecree::simple(
"golang",
vec!["go".into()],
"golang/hit",
));
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(golang);
let path = Utf8Path::new("main.go");
let sources = [Source {
path,
text: "package main",
}];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == "golang/hit"),
"golang should run on .go files"
);
assert!(
!diags.iter().any(|d| d.rule == "supreme/hit"),
"supreme should be shadowed by golang"
);
}
#[test]
fn enforce_supreme_runs_on_go_files_when_golang_not_loaded() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let mut regime = Regime::new();
regime.add_decree(supreme);
let path = Utf8Path::new("main.go");
let sources = [Source {
path,
text: "package main",
}];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == "supreme/hit"),
"supreme should run when no golang decree loaded"
);
}
#[test]
fn enforce_all_shadowers_work() {
for (name, ext, rule) in [
("ruby", "rb", "ruby/hit"),
("typescript", "ts", "typescript/hit"),
("golang", "go", "golang/hit"),
("rust", "rs", "rust/hit"),
("python", "py", "python/hit"),
] {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let lang: BoxDecree = Box::new(MockDecree::simple(name, vec![ext.into()], rule));
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(lang);
let path_str = format!("test.{ext}");
let path = Utf8Path::new(&path_str);
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == rule),
"{name} should run on .{ext} files"
);
assert!(
!diags.iter().any(|d| d.rule == "supreme/hit"),
"supreme should be shadowed by {name} on .{ext} files"
);
}
}
#[test]
fn enforce_matches_by_filename() {
let ruby: BoxDecree = Box::new(MockDecree {
name: "ruby",
exts: vec!["rb".into()],
filenames: vec!["Gemfile".into(), "Rakefile".into()],
skip: vec![],
rule: "ruby/hit",
});
let mut regime = Regime::new();
regime.add_decree(ruby);
let path = Utf8Path::new("Gemfile");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == "ruby/hit"),
"ruby should match Gemfile by filename"
);
}
#[test]
fn enforce_skips_skip_filenames() {
let ruby: BoxDecree = Box::new(MockDecree {
name: "ruby",
exts: vec!["rb".into()],
filenames: vec!["Gemfile".into()],
skip: vec!["Gemfile.lock".into()],
rule: "ruby/hit",
});
let mut regime = Regime::new();
regime.add_decree(ruby);
let path = Utf8Path::new("Gemfile.lock");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.is_empty(),
"Gemfile.lock should be skipped (owned but not linted)"
);
}
#[test]
fn enforce_skip_filenames_prevents_supreme() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let ruby: BoxDecree = Box::new(MockDecree {
name: "ruby",
exts: vec!["rb".into()],
filenames: vec!["Gemfile".into()],
skip: vec!["Gemfile.lock".into()],
rule: "ruby/hit",
});
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(ruby);
let path = Utf8Path::new("Gemfile.lock");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == "supreme/hit"),
"supreme lints files not in its skip list"
);
}
#[test]
fn enforce_filename_shadows_supreme() {
let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
let golang: BoxDecree = Box::new(MockDecree {
name: "golang",
exts: vec!["go".into()],
filenames: vec!["go.mod".into()],
skip: vec!["go.sum".into()],
rule: "golang/hit",
});
let mut regime = Regime::new();
regime.add_decree(supreme);
regime.add_decree(golang);
let path = Utf8Path::new("go.mod");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(
diags.iter().any(|d| d.rule == "golang/hit"),
"golang should match go.mod"
);
assert!(
!diags.iter().any(|d| d.rule == "supreme/hit"),
"supreme should be shadowed by golang for go.mod"
);
}
#[test]
fn enforce_golang_skips_go_sum() {
let golang: BoxDecree = Box::new(MockDecree {
name: "golang",
exts: vec!["go".into()],
filenames: vec!["go.mod".into()],
skip: vec!["go.sum".into()],
rule: "golang/hit",
});
let mut regime = Regime::new();
regime.add_decree(golang);
let path = Utf8Path::new("go.sum");
let sources = [Source { path, text: "x" }];
let diags = regime.enforce(&sources).unwrap();
assert!(diags.is_empty(), "go.sum should be skipped by golang");
}
}