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, Diagnostic, Diagnostics};
9use std::collections::{HashMap, 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 rule_ignores: HashMap<String, HashMap<String, config::RuleIgnore>>,
23}
24
25impl Default for Regime {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl Regime {
32 #[must_use]
33 pub fn new() -> Self {
34 Self {
35 decrees: Vec::new(),
36 rule_ignores: HashMap::new(),
37 }
38 }
39
40 #[must_use]
41 pub fn with_decree(mut self, decree: BoxDecree) -> Self {
42 self.decrees.push(decree);
43 self
44 }
45
46 pub fn add_decree(&mut self, decree: BoxDecree) {
47 self.decrees.push(decree);
48 }
49
50 pub fn set_rule_ignores_from_config(&mut self, config: Option<&DictateConfig>) {
55 self.rule_ignores.clear();
56
57 let Some(cfg) = config else {
58 return;
59 };
60
61 for (decree_name, settings) in &cfg.decree {
62 if settings.ignore.is_empty() {
63 continue;
64 }
65 self.rule_ignores
66 .insert(decree_name.clone(), settings.ignore.clone());
67 }
68 }
69
70 #[must_use]
77 pub fn watched_extensions(&self) -> Option<HashSet<String>> {
78 let mut exts = HashSet::new();
79 for decree in &self.decrees {
80 let supported = &decree.metadata().supported_extensions;
81 if supported.is_empty() {
82 continue; }
84 for ext in supported {
85 exts.insert(ext.to_ascii_lowercase());
86 }
87 }
88
89 if exts.is_empty() { None } else { Some(exts) }
90 }
91
92 pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<()> {
99 let decree = loader::load_decree(path.as_ref())?;
100 self.decrees.push(decree);
101 Ok(())
102 }
103
104 pub fn enforce(&self, sources: &[Source<'_>]) -> Result<Diagnostics> {
116 let mut all = Diagnostics::new();
117 for src in sources {
118 let filename = src.path.file_name().unwrap_or("");
119
120 let is_supreme_shadowed = self.is_supreme_shadowed(src.path);
123
124 for decree in &self.decrees {
125 let meta = decree.metadata();
126
127 if meta.skip_filenames.iter().any(|s| s == filename) {
129 continue;
130 }
131
132 let matches = Self::decree_matches(src.path, &meta);
134 if !matches {
135 continue;
136 }
137
138 let is_universal =
140 meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty();
141 if is_supreme_shadowed && is_universal && decree.name() == "supreme" {
142 continue;
143 }
144
145 let diags = decree.lint(src.path.as_str(), src.text);
146 for diag in diags {
147 if self.is_rule_ignored_for_path(src.path, &diag) {
148 continue;
149 }
150 all.push(diag);
151 }
152 }
153 }
154 Ok(all)
155 }
156
157 fn decree_matches(path: &Utf8Path, meta: &dictator_decree_abi::DecreeMetadata) -> bool {
159 let filename = path.file_name().unwrap_or("");
160
161 if meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty() {
163 return true;
164 }
165
166 if meta.supported_filenames.iter().any(|s| s == filename) {
168 return true;
169 }
170
171 Self::extension_matches(path, &meta.supported_extensions)
173 }
174
175 fn extension_matches(path: &Utf8Path, supported: &[String]) -> bool {
177 path.extension()
178 .is_some_and(|ext| supported.iter().any(|s| s == ext))
179 }
180
181 fn is_supreme_shadowed(&self, path: &Utf8Path) -> bool {
182 const SHADOWERS: [&str; 5] = ["ruby", "typescript", "golang", "rust", "python"];
185
186 self.decrees.iter().any(|decree| {
187 let name = decree.name();
188 if !SHADOWERS.contains(&name) {
189 return false;
190 }
191
192 let meta = decree.metadata();
193
194 Self::decree_matches(path, &meta)
196 })
197 }
198
199 fn is_rule_ignored_for_path(&self, path: &Utf8Path, diag: &Diagnostic) -> bool {
200 if self.rule_ignores.is_empty() {
201 return false;
202 }
203
204 let Some((decree, rule_name)) = diag.rule.split_once('/') else {
205 return false;
206 };
207
208 let Some(rules) = self.rule_ignores.get(decree) else {
209 return false;
210 };
211 let Some(ignore) = rules.get(rule_name) else {
212 return false;
213 };
214
215 let filename = path.file_name().unwrap_or("");
216 if ignore.filenames.iter().any(|f| f == filename) {
217 return true;
218 }
219
220 let Some(ext) = path.extension() else {
221 return false;
222 };
223 ignore
224 .extensions
225 .iter()
226 .any(|e| e.eq_ignore_ascii_case(ext))
227 }
228}
229
230mod loader {
231 use anyhow::{Context, Result};
232 use dictator_decree_abi::{BoxDecree, Diagnostics, Span};
233 use libloading::Library;
234 use std::path::Path;
235 use std::sync::Mutex;
236 use wasmtime::component::{Component, Linker, ResourceTable};
237 use wasmtime::{Config, Engine, Store};
238 use wasmtime_wasi::p2::add_to_linker_sync;
239 use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
240
241 mod bindings {
242 wasmtime::component::bindgen!({ path: "wit/decree.wit", world: "decree" });
243 }
244
245 #[allow(unsafe_code)]
253 fn load_native(lib_path: &Path) -> Result<BoxDecree> {
254 use dictator_decree_abi::{ABI_VERSION, DECREE_FACTORY_EXPORT, DecreeFactory};
255
256 static LOADED_LIBRARIES: std::sync::OnceLock<std::sync::Mutex<Vec<Library>>> =
260 std::sync::OnceLock::new();
261
262 unsafe {
263 let lib = Library::new(lib_path)
264 .with_context(|| format!("failed to load native decree: {}", lib_path.display()))?;
265 let ctor: libloading::Symbol<DecreeFactory> =
266 lib.get(DECREE_FACTORY_EXPORT.as_bytes()).with_context(|| {
267 format!(
268 "missing symbol {} in {}",
269 DECREE_FACTORY_EXPORT,
270 lib_path.display()
271 )
272 })?;
273
274 let decree = ctor();
275
276 let metadata = decree.metadata();
278 metadata.validate_abi(ABI_VERSION).map_err(|e| {
279 anyhow::anyhow!(
280 "Decree '{}' from {}: {}",
281 decree.name(),
282 lib_path.display(),
283 e
284 )
285 })?;
286
287 tracing::info!(
288 "Loaded decree '{}' v{} (ABI {})",
289 decree.name(),
290 metadata.decree_version,
291 metadata.abi_version
292 );
293
294 LOADED_LIBRARIES
296 .get_or_init(std::sync::Mutex::default)
297 .lock()
298 .expect("loaded libraries mutex poisoned")
299 .push(lib);
300
301 Ok(decree)
302 }
303 }
304
305 use self::bindings::exports::dictator::decree::lints as guest;
306
307 struct HostState {
308 table: ResourceTable,
309 wasi: WasiCtx,
310 }
311
312 impl WasiView for HostState {
313 fn ctx(&mut self) -> WasiCtxView<'_> {
314 WasiCtxView {
315 ctx: &mut self.wasi,
316 table: &mut self.table,
317 }
318 }
319 }
320
321 struct WasmDecree {
322 name: String,
323 metadata: dictator_decree_abi::DecreeMetadata,
324 state: Mutex<WasmState>,
325 }
326
327 struct WasmState {
328 store: Store<HostState>,
329 plugin: bindings::Decree,
330 }
331
332 impl dictator_decree_abi::Decree for WasmDecree {
333 fn name(&self) -> &str {
334 &self.name
335 }
336
337 #[allow(clippy::significant_drop_tightening)]
338 fn lint(&self, path: &str, source: &str) -> Diagnostics {
339 let result = {
340 let mut guard = self.state.lock().expect("wasm store poisoned");
341 let WasmState { plugin, store } = &mut *guard;
342 plugin
343 .dictator_decree_lints()
344 .call_lint(store, path, source)
345 .unwrap_or_default()
346 };
347 result
348 .into_iter()
349 .map(|d| dictator_decree_abi::Diagnostic {
350 rule: d.rule,
351 message: d.message,
352 enforced: matches!(d.severity, guest::Severity::Info), span: Span {
354 start: d.span.start as usize,
355 end: d.span.end as usize,
356 },
357 })
358 .collect()
359 }
360
361 fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
362 self.metadata.clone()
363 }
364 }
365
366 fn load_wasm(lib_path: &Path) -> Result<BoxDecree> {
367 use dictator_decree_abi::ABI_VERSION;
368
369 let mut config = Config::new();
370 config.wasm_component_model(true);
371 let engine = Engine::new(&config)?;
372 let component = Component::from_file(&engine, lib_path)
373 .with_context(|| format!("failed to load wasm decree: {}", lib_path.display()))?;
374 let mut linker: Linker<HostState> = Linker::new(&engine);
375 add_to_linker_sync(&mut linker)?;
376 let host_state = HostState {
377 table: ResourceTable::new(),
378 wasi: WasiCtxBuilder::new().inherit_stdio().build(),
379 };
380 let mut store = Store::new(&engine, host_state);
381 let plugin = bindings::Decree::instantiate(&mut store, &component, &linker)?;
382 let guest = plugin.dictator_decree_lints();
383
384 let name = guest
385 .call_name(&mut store)
386 .unwrap_or_else(|_| "wasm-decree".to_string());
387
388 let wasm_meta = guest
390 .call_metadata(&mut store)
391 .context("failed to call metadata on wasm decree")?;
392
393 let metadata = dictator_decree_abi::DecreeMetadata {
394 abi_version: wasm_meta.abi_version,
395 decree_version: wasm_meta.decree_version,
396 description: wasm_meta.description,
397 dectauthors: wasm_meta.dectauthors,
398 supported_extensions: wasm_meta.supported_extensions,
399 supported_filenames: wasm_meta.supported_filenames,
400 skip_filenames: wasm_meta.skip_filenames,
401 capabilities: wasm_meta
402 .capabilities
403 .into_iter()
404 .map(|c| match c {
405 guest::Capability::Lint => dictator_decree_abi::Capability::Lint,
406 guest::Capability::AutoFix => dictator_decree_abi::Capability::AutoFix,
407 guest::Capability::Streaming => dictator_decree_abi::Capability::Streaming,
408 guest::Capability::RuntimeConfig => {
409 dictator_decree_abi::Capability::RuntimeConfig
410 }
411 guest::Capability::RichDiagnostics => {
412 dictator_decree_abi::Capability::RichDiagnostics
413 }
414 })
415 .collect(),
416 };
417
418 metadata
419 .validate_abi(ABI_VERSION)
420 .map_err(|e| anyhow::anyhow!("Decree '{}' from {}: {}", name, lib_path.display(), e))?;
421
422 tracing::info!(
423 "Loaded WASM decree '{}' v{} (ABI {})",
424 name,
425 metadata.decree_version,
426 metadata.abi_version
427 );
428
429 Ok(Box::new(WasmDecree {
430 name,
431 metadata,
432 state: Mutex::new(WasmState { store, plugin }),
433 }))
434 }
435
436 pub fn load_decree(path: &Path) -> Result<BoxDecree> {
437 match path.extension().and_then(|s| s.to_str()) {
438 Some("wasm") => load_wasm(path),
439 _ => load_native(path),
440 }
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use dictator_decree_abi::{Capability, Decree, DecreeMetadata, Diagnostics};
448 use dictator_decree_abi::{Diagnostic, Span};
449
450 struct MockDecree {
451 name: &'static str,
452 exts: Vec<String>,
453 filenames: Vec<String>,
454 skip: Vec<String>,
455 rule: &'static str,
456 }
457
458 impl MockDecree {
459 fn simple(name: &'static str, exts: Vec<String>, rule: &'static str) -> Self {
460 Self {
461 name,
462 exts,
463 filenames: vec![],
464 skip: vec![],
465 rule,
466 }
467 }
468 }
469
470 impl Decree for MockDecree {
471 fn name(&self) -> &str {
472 self.name
473 }
474
475 fn lint(&self, _path: &str, _source: &str) -> Diagnostics {
476 vec![Diagnostic {
477 rule: self.rule.to_string(),
478 message: format!("hit {}", self.name),
479 span: Span::new(0, 0),
480 enforced: false,
481 }]
482 }
483
484 fn metadata(&self) -> DecreeMetadata {
485 DecreeMetadata {
486 abi_version: "1".into(),
487 decree_version: "1".into(),
488 description: String::new(),
489 dectauthors: None,
490 supported_extensions: self.exts.clone(),
491 supported_filenames: self.filenames.clone(),
492 skip_filenames: self.skip.clone(),
493 capabilities: vec![Capability::Lint],
494 }
495 }
496 }
497
498 #[test]
499 fn watched_extensions_unites_declared_sets() {
500 let decree_a: BoxDecree = Box::new(MockDecree::simple(
501 "a",
502 vec!["rs".into(), "Rb".into()],
503 "a/hit",
504 ));
505 let decree_b: BoxDecree = Box::new(MockDecree::simple("b", vec!["ts".into()], "b/hit"));
506 let mut regime = Regime::new();
507 regime.add_decree(decree_a);
508 regime.add_decree(decree_b);
509
510 let exts = regime.watched_extensions().unwrap();
511 assert!(exts.contains("rs"));
512 assert!(exts.contains("rb"));
513 assert!(exts.contains("ts"));
514 assert_eq!(exts.len(), 3);
515 }
516
517 #[test]
518 fn watched_extensions_none_when_only_universal() {
519 let sup: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
520 let mut regime = Regime::new();
521 regime.add_decree(sup);
522
523 assert!(regime.watched_extensions().is_none());
524 }
525
526 #[test]
527 fn enforce_skips_supreme_when_language_specific_matches() {
528 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
529 let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
530
531 let mut regime = Regime::new();
532 regime.add_decree(supreme);
533 regime.add_decree(ruby);
534
535 let path = Utf8Path::new("test.rb");
536 let sources = [Source { path, text: "x" }];
537
538 let diags = regime.enforce(&sources).unwrap();
539 assert!(diags.iter().any(|d| d.rule == "ruby/hit"));
540 assert!(!diags.iter().any(|d| d.rule == "supreme/hit"));
541 }
542
543 #[test]
544 fn enforce_runs_supreme_when_language_specific_does_not_match() {
545 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
546 let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
547
548 let mut regime = Regime::new();
549 regime.add_decree(supreme);
550 regime.add_decree(ruby);
551
552 let path = Utf8Path::new("test.txt");
553 let sources = [Source { path, text: "x" }];
554
555 let diags = regime.enforce(&sources).unwrap();
556 assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
557 assert!(!diags.iter().any(|d| d.rule == "ruby/hit"));
558 }
559
560 #[test]
561 fn enforce_ignores_configured_rules_by_filename() {
562 let supreme: BoxDecree = Box::new(MockDecree::simple(
563 "supreme",
564 vec![],
565 "supreme/tab-character",
566 ));
567
568 let mut settings = DecreeSettings::default();
569 settings.ignore.insert(
570 "tab-character".to_string(),
571 crate::config::RuleIgnore {
572 filenames: vec!["Makefile".to_string()],
573 extensions: vec![],
574 },
575 );
576 let mut config = DictateConfig::default();
577 config.decree.insert("supreme".to_string(), settings);
578
579 let mut regime = Regime::new();
580 regime.set_rule_ignores_from_config(Some(&config));
581 regime.add_decree(supreme);
582
583 let path = Utf8Path::new("Makefile");
584 let sources = [Source { path, text: "x" }];
585 let diags = regime.enforce(&sources).unwrap();
586 assert!(diags.is_empty(), "rule should be ignored for Makefile");
587 }
588
589 #[test]
590 fn enforce_ignores_configured_rules_by_extension() {
591 let supreme: BoxDecree = Box::new(MockDecree::simple(
592 "supreme",
593 vec![],
594 "supreme/tab-character",
595 ));
596
597 let mut settings = DecreeSettings::default();
598 settings.ignore.insert(
599 "tab-character".to_string(),
600 crate::config::RuleIgnore {
601 filenames: vec![],
602 extensions: vec!["md".to_string(), "MDX".to_string()],
603 },
604 );
605 let mut config = DictateConfig::default();
606 config.decree.insert("supreme".to_string(), settings);
607
608 let mut regime = Regime::new();
609 regime.set_rule_ignores_from_config(Some(&config));
610 regime.add_decree(supreme);
611
612 let path = Utf8Path::new("README.md");
613 let sources = [Source { path, text: "x" }];
614 let diags = regime.enforce(&sources).unwrap();
615 assert!(diags.is_empty(), "rule should be ignored for .md");
616
617 let path = Utf8Path::new("doc.mdx");
618 let sources = [Source { path, text: "x" }];
619 let diags = regime.enforce(&sources).unwrap();
620 assert!(diags.is_empty(), "rule should be ignored for .mdx");
621 }
622
623 #[test]
624 fn enforce_does_not_ignore_unconfigured_rules() {
625 let supreme: BoxDecree = Box::new(MockDecree::simple(
626 "supreme",
627 vec![],
628 "supreme/trailing-whitespace",
629 ));
630
631 let mut settings = DecreeSettings::default();
632 settings.ignore.insert(
633 "tab-character".to_string(),
634 crate::config::RuleIgnore {
635 filenames: vec!["Makefile".to_string()],
636 extensions: vec!["md".to_string()],
637 },
638 );
639 let mut config = DictateConfig::default();
640 config.decree.insert("supreme".to_string(), settings);
641
642 let mut regime = Regime::new();
643 regime.set_rule_ignores_from_config(Some(&config));
644 regime.add_decree(supreme);
645
646 let path = Utf8Path::new("README.md");
647 let sources = [Source { path, text: "x" }];
648 let diags = regime.enforce(&sources).unwrap();
649 assert!(
650 diags
651 .iter()
652 .any(|d| d.rule == "supreme/trailing-whitespace"),
653 "unconfigured rules should still be reported"
654 );
655 }
656
657 #[test]
658 fn enforce_does_not_shadow_supreme_for_non_language_decree() {
659 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
660 let frontmatter: BoxDecree = Box::new(MockDecree::simple(
661 "frontmatter",
662 vec!["md".into()],
663 "frontmatter/hit",
664 ));
665
666 let mut regime = Regime::new();
667 regime.add_decree(supreme);
668 regime.add_decree(frontmatter);
669
670 let path = Utf8Path::new("README.md");
671 let sources = [Source { path, text: "x" }];
672
673 let diags = regime.enforce(&sources).unwrap();
674 assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
675 assert!(diags.iter().any(|d| d.rule == "frontmatter/hit"));
676 }
677
678 #[test]
679 fn enforce_golang_shadows_supreme_for_go_files() {
680 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
681 let golang: BoxDecree = Box::new(MockDecree::simple(
682 "golang",
683 vec!["go".into()],
684 "golang/hit",
685 ));
686
687 let mut regime = Regime::new();
688 regime.add_decree(supreme);
689 regime.add_decree(golang);
690
691 let path = Utf8Path::new("main.go");
692 let sources = [Source {
693 path,
694 text: "package main",
695 }];
696
697 let diags = regime.enforce(&sources).unwrap();
698 assert!(
699 diags.iter().any(|d| d.rule == "golang/hit"),
700 "golang should run on .go files"
701 );
702 assert!(
703 !diags.iter().any(|d| d.rule == "supreme/hit"),
704 "supreme should be shadowed by golang"
705 );
706 }
707
708 #[test]
709 fn enforce_supreme_runs_on_go_files_when_golang_not_loaded() {
710 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
711
712 let mut regime = Regime::new();
713 regime.add_decree(supreme);
714
715 let path = Utf8Path::new("main.go");
716 let sources = [Source {
717 path,
718 text: "package main",
719 }];
720
721 let diags = regime.enforce(&sources).unwrap();
722 assert!(
723 diags.iter().any(|d| d.rule == "supreme/hit"),
724 "supreme should run when no golang decree loaded"
725 );
726 }
727
728 #[test]
729 fn enforce_all_shadowers_work() {
730 for (name, ext, rule) in [
732 ("ruby", "rb", "ruby/hit"),
733 ("typescript", "ts", "typescript/hit"),
734 ("golang", "go", "golang/hit"),
735 ("rust", "rs", "rust/hit"),
736 ("python", "py", "python/hit"),
737 ] {
738 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
739 let lang: BoxDecree = Box::new(MockDecree::simple(name, vec![ext.into()], rule));
740
741 let mut regime = Regime::new();
742 regime.add_decree(supreme);
743 regime.add_decree(lang);
744
745 let path_str = format!("test.{ext}");
746 let path = Utf8Path::new(&path_str);
747 let sources = [Source { path, text: "x" }];
748
749 let diags = regime.enforce(&sources).unwrap();
750 assert!(
751 diags.iter().any(|d| d.rule == rule),
752 "{name} should run on .{ext} files"
753 );
754 assert!(
755 !diags.iter().any(|d| d.rule == "supreme/hit"),
756 "supreme should be shadowed by {name} on .{ext} files"
757 );
758 }
759 }
760
761 #[test]
764 fn enforce_matches_by_filename() {
765 let ruby: BoxDecree = Box::new(MockDecree {
766 name: "ruby",
767 exts: vec!["rb".into()],
768 filenames: vec!["Gemfile".into(), "Rakefile".into()],
769 skip: vec![],
770 rule: "ruby/hit",
771 });
772
773 let mut regime = Regime::new();
774 regime.add_decree(ruby);
775
776 let path = Utf8Path::new("Gemfile");
778 let sources = [Source { path, text: "x" }];
779 let diags = regime.enforce(&sources).unwrap();
780 assert!(
781 diags.iter().any(|d| d.rule == "ruby/hit"),
782 "ruby should match Gemfile by filename"
783 );
784 }
785
786 #[test]
787 fn enforce_skips_skip_filenames() {
788 let ruby: BoxDecree = Box::new(MockDecree {
789 name: "ruby",
790 exts: vec!["rb".into()],
791 filenames: vec!["Gemfile".into()],
792 skip: vec!["Gemfile.lock".into()],
793 rule: "ruby/hit",
794 });
795
796 let mut regime = Regime::new();
797 regime.add_decree(ruby);
798
799 let path = Utf8Path::new("Gemfile.lock");
801 let sources = [Source { path, text: "x" }];
802 let diags = regime.enforce(&sources).unwrap();
803 assert!(
804 diags.is_empty(),
805 "Gemfile.lock should be skipped (owned but not linted)"
806 );
807 }
808
809 #[test]
810 fn enforce_skip_filenames_prevents_supreme() {
811 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
812 let ruby: BoxDecree = Box::new(MockDecree {
813 name: "ruby",
814 exts: vec!["rb".into()],
815 filenames: vec!["Gemfile".into()],
816 skip: vec!["Gemfile.lock".into()],
817 rule: "ruby/hit",
818 });
819
820 let mut regime = Regime::new();
821 regime.add_decree(supreme);
822 regime.add_decree(ruby);
823
824 let path = Utf8Path::new("Gemfile.lock");
826 let sources = [Source { path, text: "x" }];
827 let diags = regime.enforce(&sources).unwrap();
828
829 assert!(
837 diags.iter().any(|d| d.rule == "supreme/hit"),
838 "supreme lints files not in its skip list"
839 );
840 }
841
842 #[test]
843 fn enforce_filename_shadows_supreme() {
844 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
845 let golang: BoxDecree = Box::new(MockDecree {
846 name: "golang",
847 exts: vec!["go".into()],
848 filenames: vec!["go.mod".into()],
849 skip: vec!["go.sum".into()],
850 rule: "golang/hit",
851 });
852
853 let mut regime = Regime::new();
854 regime.add_decree(supreme);
855 regime.add_decree(golang);
856
857 let path = Utf8Path::new("go.mod");
859 let sources = [Source { path, text: "x" }];
860 let diags = regime.enforce(&sources).unwrap();
861 assert!(
862 diags.iter().any(|d| d.rule == "golang/hit"),
863 "golang should match go.mod"
864 );
865 assert!(
866 !diags.iter().any(|d| d.rule == "supreme/hit"),
867 "supreme should be shadowed by golang for go.mod"
868 );
869 }
870
871 #[test]
872 fn enforce_golang_skips_go_sum() {
873 let golang: BoxDecree = Box::new(MockDecree {
874 name: "golang",
875 exts: vec!["go".into()],
876 filenames: vec!["go.mod".into()],
877 skip: vec!["go.sum".into()],
878 rule: "golang/hit",
879 });
880
881 let mut regime = Regime::new();
882 regime.add_decree(golang);
883
884 let path = Utf8Path::new("go.sum");
886 let sources = [Source { path, text: "x" }];
887 let diags = regime.enforce(&sources).unwrap();
888 assert!(diags.is_empty(), "go.sum should be skipped by golang");
889 }
890}