1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4use std::time::Instant;
5
6use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
7use code_moniker_core::lang::Lang;
8use code_moniker_workspace::environment;
9use code_moniker_workspace::lang::path_to_lang;
10
11use crate::check;
12use crate::check::config::{self, RuleSeverity};
13use crate::check::eval::CompiledRuleSpec;
14use crate::check::expr::Domain;
15
16#[derive(Clone, Debug)]
19pub struct FileReport {
20 pub path: PathBuf,
21 pub violations: Vec<check::Violation>,
22 pub rule_reports: Vec<check::RuleReport>,
23}
24
25#[derive(Clone, Debug)]
28pub struct FileError {
29 pub path: PathBuf,
30 pub error: String,
31}
32
33pub trait CheckWorkspace: Sync {
34 fn is_dir(&self, path: &Path) -> anyhow::Result<bool>;
35 fn read_to_string(&self, path: &Path) -> anyhow::Result<String>;
36 fn source_set(
37 &self,
38 root: &Path,
39 files: &[PathBuf],
40 ) -> anyhow::Result<environment::SourceFileSet>;
41 fn exists(&self, path: &Path) -> bool;
42}
43
44#[derive(Clone, Copy, Debug, Default)]
45pub struct FsCheckWorkspace;
46
47impl CheckWorkspace for FsCheckWorkspace {
48 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
49 let meta = std::fs::metadata(path)
50 .map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
51 Ok(meta.is_dir())
52 }
53
54 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
55 std::fs::read_to_string(path)
56 .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))
57 }
58
59 fn source_set(
60 &self,
61 root: &Path,
62 files: &[PathBuf],
63 ) -> anyhow::Result<environment::SourceFileSet> {
64 if files.is_empty() {
65 environment::discover_sources(&[root.to_path_buf()], None)
66 } else {
67 environment::discover_source_files(root, files, None)
68 }
69 }
70
71 fn exists(&self, path: &Path) -> bool {
72 path.exists()
73 }
74}
75
76#[derive(Clone, Debug)]
77pub struct MemoryCheckWorkspace {
78 root: PathBuf,
79 files: BTreeMap<PathBuf, MemorySourceFile>,
80}
81
82#[derive(Clone, Debug)]
83struct MemorySourceFile {
84 body: String,
85 lang: Lang,
86}
87
88impl MemoryCheckWorkspace {
89 pub fn new(root: impl Into<PathBuf>) -> Self {
90 Self {
91 root: root.into(),
92 files: BTreeMap::new(),
93 }
94 }
95
96 pub fn with_file(
97 mut self,
98 path: impl Into<PathBuf>,
99 body: impl Into<String>,
100 lang: Lang,
101 ) -> Self {
102 self.files.insert(
103 normalize_relative(path.into()),
104 MemorySourceFile {
105 body: body.into(),
106 lang,
107 },
108 );
109 self
110 }
111
112 pub fn root(&self) -> &Path {
113 &self.root
114 }
115
116 fn rel_path(&self, path: &Path) -> PathBuf {
117 normalize_relative(path.strip_prefix(&self.root).unwrap_or(path).to_path_buf())
118 }
119}
120
121impl CheckWorkspace for MemoryCheckWorkspace {
122 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
123 Ok(path == self.root || path == Path::new("."))
124 }
125
126 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
127 let rel = self.rel_path(path);
128 self.files
129 .get(&rel)
130 .map(|file| file.body.clone())
131 .ok_or_else(|| anyhow::anyhow!("cannot read {}: not found", path.display()))
132 }
133
134 fn source_set(
135 &self,
136 root: &Path,
137 files: &[PathBuf],
138 ) -> anyhow::Result<environment::SourceFileSet> {
139 self.ensure_root(root)?;
140 Ok(environment::SourceFileSet {
141 roots: vec![memory_source_root(&self.root)],
142 files: memory_source_files(&self.root, &self.files, files),
143 multi: false,
144 })
145 }
146
147 fn exists(&self, path: &Path) -> bool {
148 let rel = self.rel_path(path);
149 self.files.contains_key(&rel)
150 }
151}
152
153impl MemoryCheckWorkspace {
154 fn ensure_root(&self, root: &Path) -> anyhow::Result<()> {
155 if root == self.root {
156 return Ok(());
157 }
158 anyhow::bail!(
159 "memory workspace root mismatch: expected {}, got {}",
160 self.root.display(),
161 root.display()
162 );
163 }
164}
165
166fn memory_source_root(root: &Path) -> environment::SourceRoot {
167 environment::SourceRoot {
168 input: root.to_path_buf(),
169 path: root.to_path_buf(),
170 label: ".".to_string(),
171 ctx: environment::ExtractContext::default(),
172 }
173}
174
175fn memory_source_files(
176 root: &Path,
177 files: &BTreeMap<PathBuf, MemorySourceFile>,
178 requested: &[PathBuf],
179) -> Vec<environment::SourceFile> {
180 files
181 .iter()
182 .filter(|(path, _)| memory_file_selected(root, path, requested))
183 .map(|(rel_path, file)| environment::SourceFile {
184 source: 0,
185 path: root.join(rel_path),
186 rel_path: rel_path.clone(),
187 anchor: rel_path.clone(),
188 lang: file.lang,
189 retired: false,
190 })
191 .collect()
192}
193
194fn memory_file_selected(root: &Path, path: &Path, requested: &[PathBuf]) -> bool {
195 requested.is_empty()
196 || requested.iter().any(|candidate| {
197 let candidate = normalize_relative(candidate.clone());
198 candidate == path || normalize_relative(root.join(&candidate)) == path
199 })
200}
201
202#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
204pub enum DefaultRulesSelection {
205 #[default]
206 Config,
207 Enabled,
208 Disabled,
209}
210
211impl DefaultRulesSelection {
212 pub fn from_override(value: Option<bool>) -> Self {
213 match value {
214 Some(true) => Self::Enabled,
215 Some(false) => Self::Disabled,
216 None => Self::Config,
217 }
218 }
219
220 pub fn as_override(self) -> Option<bool> {
221 match self {
222 Self::Config => None,
223 Self::Enabled => Some(true),
224 Self::Disabled => Some(false),
225 }
226 }
227}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct RuleSetRequest {
232 pub rules: Option<PathBuf>,
233 pub inline_rules: Vec<String>,
234 pub default_rules: DefaultRulesSelection,
235 pub profile: Option<String>,
236 pub scheme: String,
237}
238
239impl RuleSetRequest {
240 pub fn new(rules: Option<PathBuf>, scheme: impl Into<String>) -> Self {
241 Self {
242 rules,
243 inline_rules: Vec::new(),
244 default_rules: DefaultRulesSelection::Config,
245 profile: None,
246 scheme: scheme.into(),
247 }
248 }
249
250 pub fn with_rules(rules: impl Into<PathBuf>, scheme: impl Into<String>) -> Self {
251 Self::new(Some(rules.into()), scheme)
252 }
253
254 pub fn with_default_rules(mut self, default_rules: DefaultRulesSelection) -> Self {
255 self.default_rules = default_rules;
256 self
257 }
258
259 pub fn with_inline_rules(mut self, inline_rules: Vec<String>) -> Self {
260 self.inline_rules = inline_rules;
261 self
262 }
263
264 pub fn with_profile(mut self, profile: Option<String>) -> Self {
265 self.profile = profile;
266 self
267 }
268
269 pub fn rules_path(&self) -> Option<&Path> {
270 self.rules.as_deref()
271 }
272
273 pub fn scheme(&self) -> &str {
274 &self.scheme
275 }
276
277 pub fn load_config(&self) -> anyhow::Result<check::Config> {
278 let mut cfg = config::load_with_cli_sources(
279 self.rules_path(),
280 &self.inline_rules,
281 self.default_rules.as_override(),
282 )?;
283 if let Some(profile) = &self.profile {
284 cfg.apply_profile(profile)?;
285 }
286 Ok(cfg)
287 }
288
289 pub fn compiled_specs_for_langs(
290 &self,
291 langs: impl IntoIterator<Item = Lang>,
292 ) -> anyhow::Result<Vec<CompiledRuleSpec>> {
293 let cfg = self.load_config()?;
294 compiled_specs_with_config(&cfg, langs, &self.scheme)
295 }
296
297 pub fn check_source(
298 &self,
299 source: &str,
300 anchor: &Path,
301 lang: Lang,
302 report: bool,
303 ) -> anyhow::Result<SourceReport> {
304 let cfg = self.load_config()?;
305 check_source_with_config(&cfg, source, anchor, lang, &self.scheme, report)
306 }
307}
308
309#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct CheckRequest {
313 pub path: PathBuf,
314 pub rules: RuleSetRequest,
315 pub report: bool,
316 pub files: Vec<PathBuf>,
317}
318
319impl CheckRequest {
320 pub fn new(path: impl Into<PathBuf>, rules: RuleSetRequest) -> Self {
321 Self {
322 path: path.into(),
323 rules,
324 report: false,
325 files: Vec::new(),
326 }
327 }
328
329 pub fn with_report(mut self, report: bool) -> Self {
330 self.report = report;
331 self
332 }
333
334 pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
335 self.files = files;
336 self
337 }
338
339 pub fn run(&self) -> anyhow::Result<CheckRun> {
340 self.run_with_workspace(&FsCheckWorkspace)
341 }
342
343 pub fn run_with_workspace(&self, workspace: &dyn CheckWorkspace) -> anyhow::Result<CheckRun> {
344 let started = Instant::now();
345 let cfg = self.rules.load_config()?;
346 let (reports, errors, skip_reason) = if workspace.is_dir(&self.path)? {
347 self.run_directory(&cfg, workspace)?
348 } else {
349 self.run_single_file(&cfg, workspace)?
350 };
351 Ok(CheckRun {
352 reports,
353 errors,
354 elapsed_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
355 skip_reason,
356 })
357 }
358
359 fn run_directory(
360 &self,
361 cfg: &check::Config,
362 workspace: &dyn CheckWorkspace,
363 ) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
364 let (reports, errors) = if self.files.is_empty() {
365 check_project_workspace(&self.path, cfg, self.rules.scheme(), self.report, workspace)?
366 } else {
367 check_project_files_workspace(
368 &self.path,
369 &self.files,
370 cfg,
371 self.rules.scheme(),
372 self.report,
373 workspace,
374 )?
375 };
376 let skip_reason = if !self.files.is_empty() && reports.is_empty() && errors.is_empty() {
377 Some(CheckSkipReason::NoMatchingFiles)
378 } else {
379 None
380 };
381 Ok((reports, errors, skip_reason))
382 }
383
384 fn run_single_file(
385 &self,
386 cfg: &check::Config,
387 workspace: &dyn CheckWorkspace,
388 ) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
389 if !self.files.is_empty() {
390 anyhow::bail!("--file can only be used when check PATH is a directory");
391 }
392 let excluded = path_excluded(&self.path, cfg);
393 match check_one_file_workspace(
394 &self.path,
395 cfg,
396 self.rules.scheme(),
397 self.report,
398 workspace,
399 )? {
400 Some(report) => Ok((vec![report], Vec::new(), None)),
401 None if excluded => Ok((
402 Vec::new(),
403 Vec::new(),
404 Some(CheckSkipReason::ExcludedSingleFile),
405 )),
406 None => Ok((
407 Vec::new(),
408 Vec::new(),
409 Some(CheckSkipReason::UnsupportedSingleFile),
410 )),
411 }
412 }
413}
414
415#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum CheckSkipReason {
419 ExcludedSingleFile,
420 UnsupportedSingleFile,
421 NoMatchingFiles,
422}
423
424#[derive(Clone, Debug)]
427pub struct CheckRun {
428 pub reports: Vec<FileReport>,
429 pub errors: Vec<FileError>,
430 pub elapsed_ms: u64,
431 pub skip_reason: Option<CheckSkipReason>,
432}
433
434impl CheckRun {
435 pub fn any_error_violation(&self) -> bool {
436 self.reports.iter().any(|report| {
437 report
438 .violations
439 .iter()
440 .any(|violation| violation.severity.is_error())
441 })
442 }
443
444 pub fn any_error(&self) -> bool {
445 !self.errors.is_empty()
446 }
447
448 pub fn violation_counts(&self) -> ViolationCounts {
449 violation_counts(&self.reports)
450 }
451
452 pub fn summary(&self) -> CheckSummary {
453 let counts = self.violation_counts();
454 CheckSummary {
455 files_scanned: self.reports.len(),
456 files_with_violations: counts.files_with,
457 total_violations: counts.total,
458 total_rule_errors: counts.errors,
459 total_warnings: counts.warnings,
460 files_with_errors: self.errors.len(),
461 total_errors: self.errors.len(),
462 elapsed_ms: self.elapsed_ms,
463 failed_rules: self.failed_rule_summary(),
464 }
465 }
466
467 pub fn failed_rule_summary(&self) -> Vec<FailedRuleSummary> {
468 failed_rule_summary(&self.reports)
469 }
470
471 pub fn file_violations(&self) -> impl Iterator<Item = (&Path, &check::eval::Violation)> {
472 self.reports.iter().flat_map(|report| {
473 report
474 .violations
475 .iter()
476 .map(move |violation| (report.path.as_path(), violation))
477 })
478 }
479
480 pub fn error_summaries(&self) -> impl Iterator<Item = (&Path, &str)> {
481 self.errors
482 .iter()
483 .map(|error| (error.path.as_path(), error.error.as_str()))
484 }
485
486 pub fn rule_violation_totals(&self) -> std::collections::BTreeMap<&str, usize> {
487 let mut totals = std::collections::BTreeMap::new();
488 for report in &self.reports {
489 for rule in &report.rule_reports {
490 *totals.entry(rule.rule_id.as_str()).or_insert(0usize) += rule.violations;
491 }
492 }
493 totals
494 }
495}
496
497#[derive(Clone, Debug, serde::Serialize)]
499pub struct CheckSummary {
500 pub files_scanned: usize,
501 pub files_with_violations: usize,
502 pub total_violations: usize,
503 pub total_rule_errors: usize,
504 pub total_warnings: usize,
505 pub files_with_errors: usize,
506 pub total_errors: usize,
507 pub elapsed_ms: u64,
508 pub failed_rules: Vec<FailedRuleSummary>,
509}
510
511#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
513pub struct FailedRuleSummary {
514 pub rule_id: String,
515 pub severity: RuleSeverity,
516 pub violations: usize,
517}
518
519#[derive(Clone, Debug, Default, Eq, PartialEq)]
521pub struct ViolationCounts {
522 pub total: usize,
523 pub errors: usize,
524 pub warnings: usize,
525 pub files_with: usize,
526}
527
528#[derive(Clone, Debug)]
530pub struct SourceReport {
531 pub rules: Vec<CompiledRuleSpec>,
532 pub violations: Vec<check::Violation>,
533 pub rule_reports: Vec<check::RuleReport>,
534}
535
536pub fn check_source_with_config(
537 cfg: &check::Config,
538 source: &str,
539 anchor: &Path,
540 lang: Lang,
541 scheme: &str,
542 report: bool,
543) -> anyhow::Result<SourceReport> {
544 let graph = environment::extract_source_with(
545 lang,
546 source,
547 anchor,
548 &environment::ExtractContext::default(),
549 );
550 check_graph_with_config(cfg, &graph, source, lang, scheme, report)
551}
552
553pub fn check_graph_with_config(
554 cfg: &check::Config,
555 graph: &CodeGraph,
556 source: &str,
557 lang: Lang,
558 scheme: &str,
559 report: bool,
560) -> anyhow::Result<SourceReport> {
561 let compiled = check::compile_rules(cfg, lang, scheme)?;
562 let raw = check::evaluate_compiled(graph, source, lang, scheme, &compiled);
563 let violations = check::apply_suppressions(graph, source, raw);
564 let rule_reports = if report {
565 let mut rule_reports = check::rule_report_compiled(graph, source, lang, scheme, &compiled);
566 align_report_violations_with_suppressions(&mut rule_reports, &violations);
567 rule_reports
568 } else {
569 Vec::new()
570 };
571 Ok(SourceReport {
572 rules: compiled.specs(lang),
573 violations,
574 rule_reports,
575 })
576}
577
578pub fn compiled_specs_with_config(
579 cfg: &check::Config,
580 langs: impl IntoIterator<Item = Lang>,
581 scheme: &str,
582) -> anyhow::Result<Vec<CompiledRuleSpec>> {
583 let mut specs = Vec::new();
584 for lang in langs {
585 let compiled = check::compile_rules(cfg, lang, scheme)?;
586 specs.extend(compiled.specs(lang));
587 }
588 specs.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
589 Ok(specs)
590}
591
592pub fn check_one_file(
593 path: &Path,
594 cfg: &check::Config,
595 scheme: &str,
596 report: bool,
597) -> anyhow::Result<Option<FileReport>> {
598 check_one_file_workspace(path, cfg, scheme, report, &FsCheckWorkspace)
599}
600
601pub fn check_one_file_workspace(
602 path: &Path,
603 cfg: &check::Config,
604 scheme: &str,
605 report: bool,
606 workspace: &dyn CheckWorkspace,
607) -> anyhow::Result<Option<FileReport>> {
608 let Ok(lang) = path_to_lang(path) else {
609 return Ok(None);
610 };
611 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
612 if excludes.matches_path(path) {
613 return Ok(None);
614 }
615 let compiled = check::compile_rules(cfg, lang, scheme)?;
616 let ctx = CompiledCheck {
617 scheme,
618 compiled: &compiled,
619 report,
620 workspace,
621 requirements: None,
622 };
623 check_one_compiled(path, None, lang, &ctx).map(Some)
624}
625
626struct CompiledCheck<'a> {
627 scheme: &'a str,
628 compiled: &'a check::CompiledRules,
629 report: bool,
630 workspace: &'a dyn CheckWorkspace,
631 requirements: Option<&'a dyn check::RequirementResolver>,
632}
633
634fn check_one_compiled(
638 fs_path: &Path,
639 moniker_anchor: Option<&Path>,
640 lang: code_moniker_core::lang::Lang,
641 ctx: &CompiledCheck<'_>,
642) -> anyhow::Result<FileReport> {
643 let source = ctx.workspace.read_to_string(fs_path)?;
644 let graph = environment::extract_source_with(
645 lang,
646 &source,
647 moniker_anchor.unwrap_or(fs_path),
648 &environment::ExtractContext::default(),
649 );
650 let raw = check::evaluate_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
651 let violations = check::apply_suppressions(&graph, &source, raw);
652 let rule_reports = if ctx.report {
653 let mut rule_reports =
654 check::rule_report_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
655 align_report_violations_with_suppressions(&mut rule_reports, &violations);
656 rule_reports
657 } else {
658 Vec::new()
659 };
660 Ok(FileReport {
661 path: fs_path.to_path_buf(),
662 violations,
663 rule_reports,
664 })
665}
666
667fn check_source_file_compiled(
668 file: &environment::SourceFile,
669 ctx: &environment::ExtractContext,
670 check_ctx: &CompiledCheck<'_>,
671) -> anyhow::Result<FileReport> {
672 let source = check_ctx.workspace.read_to_string(&file.path)?;
673 let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
674 let raw = check::evaluate_compiled_with_requirements(
675 &graph,
676 &source,
677 file.lang,
678 check_ctx.scheme,
679 check_ctx.compiled,
680 check_ctx.requirements,
681 );
682 let violations = check::apply_suppressions(&graph, &source, raw);
683 let rule_reports = if check_ctx.report {
684 let mut rule_reports = check::rule_report_compiled_with_requirements(
685 &graph,
686 &source,
687 file.lang,
688 check_ctx.scheme,
689 check_ctx.compiled,
690 check_ctx.requirements,
691 );
692 align_report_violations_with_suppressions(&mut rule_reports, &violations);
693 rule_reports
694 } else {
695 Vec::new()
696 };
697 Ok(FileReport {
698 path: file.path.clone(),
699 violations,
700 rule_reports,
701 })
702}
703
704pub fn check_project(
708 root: &Path,
709 cfg: &check::Config,
710 scheme: &str,
711 report: bool,
712) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
713 check_project_workspace(root, cfg, scheme, report, &FsCheckWorkspace)
714}
715
716pub fn check_project_workspace(
717 root: &Path,
718 cfg: &check::Config,
719 scheme: &str,
720 report: bool,
721 workspace: &dyn CheckWorkspace,
722) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
723 let source_set = workspace.source_set(root, &[])?;
724 let requirements = FileRequirementResolver::new(
725 root.to_path_buf(),
726 Some(filtered_source_set(&source_set, cfg)),
727 workspace,
728 );
729 check_source_set(
730 &source_set,
731 cfg,
732 scheme,
733 report,
734 Some(&requirements),
735 workspace,
736 )
737}
738
739pub fn check_project_files(
740 root: &Path,
741 files: &[PathBuf],
742 cfg: &check::Config,
743 scheme: &str,
744 report: bool,
745) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
746 check_project_files_workspace(root, files, cfg, scheme, report, &FsCheckWorkspace)
747}
748
749pub fn check_project_files_workspace(
750 root: &Path,
751 files: &[PathBuf],
752 cfg: &check::Config,
753 scheme: &str,
754 report: bool,
755 workspace: &dyn CheckWorkspace,
756) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
757 let source_set = workspace.source_set(root, files)?;
758 let resolver_source_set = workspace.source_set(root, &[])?;
759 let requirements = FileRequirementResolver::new(
760 root.to_path_buf(),
761 Some(filtered_source_set(&resolver_source_set, cfg)),
762 workspace,
763 );
764 check_source_set(
765 &source_set,
766 cfg,
767 scheme,
768 report,
769 Some(&requirements),
770 workspace,
771 )
772}
773
774fn filtered_source_set(
775 source_set: &environment::SourceFileSet,
776 cfg: &check::Config,
777) -> environment::SourceFileSet {
778 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
779 environment::SourceFileSet {
780 roots: source_set.roots.clone(),
781 files: source_set
782 .files
783 .iter()
784 .filter(|file| !excludes.matches_path(&file.path))
785 .cloned()
786 .collect(),
787 multi: source_set.multi,
788 }
789}
790
791fn check_source_set(
792 source_set: &environment::SourceFileSet,
793 cfg: &check::Config,
794 scheme: &str,
795 report: bool,
796 requirements: Option<&dyn check::RequirementResolver>,
797 workspace: &dyn CheckWorkspace,
798) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
799 use rayon::prelude::*;
800 use std::collections::HashMap;
801 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
802 let mut compiled: HashMap<code_moniker_core::lang::Lang, check::CompiledRules> = HashMap::new();
803 let files: Vec<&environment::SourceFile> = source_set
804 .files
805 .iter()
806 .filter(|f| !excludes.matches_path(&f.path))
807 .collect();
808 for f in &files {
809 if compiled.contains_key(&f.lang) {
810 continue;
811 }
812 compiled.insert(f.lang, check::compile_rules(cfg, f.lang, scheme)?);
813 }
814 let outcomes: Vec<Result<FileReport, FileError>> = files
815 .par_iter()
816 .map(|f| {
817 let f = *f;
818 let rules = &compiled[&f.lang];
819 let ctx = &source_set.roots[f.source].ctx;
820 let check_ctx = CompiledCheck {
821 scheme,
822 compiled: rules,
823 report,
824 workspace,
825 requirements,
826 };
827 check_source_file_compiled(f, ctx, &check_ctx).map_err(|e| FileError {
828 path: f.path.clone(),
829 error: format!("{e:#}"),
830 })
831 })
832 .collect();
833 let mut reports = Vec::new();
834 let mut errors = Vec::new();
835 for o in outcomes {
836 match o {
837 Ok(r) => reports.push(r),
838 Err(e) => errors.push(e),
839 }
840 }
841 reports.sort_by(|a, b| a.path.cmp(&b.path));
842 errors.sort_by(|a, b| a.path.cmp(&b.path));
843 Ok((reports, errors))
844}
845
846struct FileRequirementResolver<'a> {
847 root: PathBuf,
848 source_set: Option<environment::SourceFileSet>,
849 workspace_defs: OnceLock<Vec<DefRecord>>,
850 workspace: &'a dyn CheckWorkspace,
851}
852
853impl<'a> FileRequirementResolver<'a> {
854 fn new(
855 root: PathBuf,
856 source_set: Option<environment::SourceFileSet>,
857 workspace: &'a dyn CheckWorkspace,
858 ) -> Self {
859 Self {
860 root,
861 source_set,
862 workspace_defs: OnceLock::new(),
863 workspace,
864 }
865 }
866}
867
868impl check::RequirementResolver for FileRequirementResolver<'_> {
869 fn exists(&self, pattern: &str, _source: &DefRecord, _scheme: &str) -> bool {
870 let Some(candidates) = source_candidates_from_requirement(&self.root, pattern) else {
871 return false;
872 };
873 let Ok(path_pattern) = check::path::parse(pattern) else {
874 return false;
875 };
876 for path in candidates {
877 if !self.workspace.exists(&path) {
878 continue;
879 }
880 let Ok(lang) = path_to_lang(&path) else {
881 continue;
882 };
883 let Ok(source) = self.workspace.read_to_string(&path) else {
884 continue;
885 };
886 let graph = environment::extract_source_with(
887 lang,
888 &source,
889 &anchor_for_requirement(&self.root, &path),
890 &environment::ExtractContext::default(),
891 );
892 if graph
893 .defs()
894 .any(|def| check::path::matches(&path_pattern, &def.moniker))
895 {
896 return true;
897 }
898 }
899 false
900 }
901
902 fn descendant_defs<'a>(&'a self, owner: &DefRecord, inner: &Domain) -> Vec<&'a DefRecord> {
903 self.workspace_defs()
904 .iter()
905 .filter(|def| {
906 def.moniker != owner.moniker
907 && owner.moniker.is_ancestor_of(&def.moniker)
908 && lazy_domain_matches(inner, def)
909 })
910 .collect()
911 }
912}
913
914impl FileRequirementResolver<'_> {
915 fn workspace_defs(&self) -> &[DefRecord] {
916 self.workspace_defs
917 .get_or_init(|| collect_workspace_defs(self.source_set.as_ref(), self.workspace))
918 }
919}
920
921fn collect_workspace_defs(
922 source_set: Option<&environment::SourceFileSet>,
923 workspace: &dyn CheckWorkspace,
924) -> Vec<DefRecord> {
925 let Some(source_set) = source_set else {
926 return Vec::new();
927 };
928 let mut defs = Vec::new();
929 for file in &source_set.files {
930 let Ok(source) = workspace.read_to_string(&file.path) else {
931 continue;
932 };
933 let ctx = &source_set.roots[file.source].ctx;
934 let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
935 defs.extend(graph.defs().cloned());
936 }
937 defs
938}
939
940fn normalize_relative(path: PathBuf) -> PathBuf {
941 path.components()
942 .filter_map(|component| match component {
943 std::path::Component::Normal(part) => Some(PathBuf::from(part)),
944 std::path::Component::CurDir => None,
945 _ => None,
946 })
947 .collect()
948}
949
950fn lazy_domain_matches(domain: &Domain, def: &DefRecord) -> bool {
951 match domain {
952 Domain::Children(kind) => def.kind.as_ref() == kind.as_bytes(),
953 Domain::ChildrenByShape(shape) => {
954 def.shape().is_some_and(|actual| actual.as_str() == shape)
955 }
956 Domain::Descendants(inner) => lazy_domain_matches(inner, def),
957 Domain::Pairs(_)
958 | Domain::Segments
959 | Domain::OutRefs
960 | Domain::InRefs
961 | Domain::SourceOutRefs
962 | Domain::SourceInRefs
963 | Domain::SourceAncestorOutRefs
964 | Domain::SourceAncestorInRefs => false,
965 }
966}
967
968fn source_candidates_from_requirement(root: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
969 let mut dirs = Vec::new();
970 let mut module = None;
971 for step in pattern.split('/') {
972 if let Some(dir) = literal_step_name(step, "dir") {
973 dirs.push(dir.to_string());
974 } else if let Some(name) = literal_step_name(step, "module") {
975 module = Some(name.to_string());
976 }
977 }
978 let module = module?;
979 let base = dirs
980 .iter()
981 .fold(root.to_path_buf(), |path, dir| path.join(dir));
982 if module == "mod" {
983 Some(vec![base.join("mod.rs")])
984 } else {
985 Some(vec![
986 base.join(format!("{module}.rs")),
987 base.join(module).join("mod.rs"),
988 ])
989 }
990}
991
992fn literal_step_name<'a>(step: &'a str, kind: &str) -> Option<&'a str> {
993 let (step_kind, name) = step.split_once(':')?;
994 (step_kind == kind && !name.contains(['*', '{', '}', '/'])).then_some(name)
995}
996
997fn anchor_for_requirement(root: &Path, path: &Path) -> PathBuf {
998 path.strip_prefix(root).unwrap_or(path).to_path_buf()
999}
1000
1001fn align_report_violations_with_suppressions(
1002 rule_reports: &mut [check::RuleReport],
1003 violations: &[check::Violation],
1004) {
1005 use std::collections::HashMap;
1006 let mut counts: HashMap<&str, usize> = HashMap::new();
1007 for v in violations {
1008 *counts.entry(v.rule_id.as_str()).or_insert(0) += 1;
1009 }
1010 for report in rule_reports {
1011 report.violations = counts.get(report.rule_id.as_str()).copied().unwrap_or(0);
1012 }
1013}
1014
1015fn path_excluded(path: &Path, cfg: &check::Config) -> bool {
1016 check::UriExclusionMatcher::new(&cfg.exclude.uris).matches_path(path)
1017}
1018
1019fn violation_counts(reports: &[FileReport]) -> ViolationCounts {
1020 let mut counts = ViolationCounts::default();
1021 for report in reports {
1022 if report.violations.is_empty() {
1023 continue;
1024 }
1025 counts.files_with += 1;
1026 for violation in &report.violations {
1027 counts.total += 1;
1028 if violation.severity.is_error() {
1029 counts.errors += 1;
1030 } else {
1031 counts.warnings += 1;
1032 }
1033 }
1034 }
1035 counts
1036}
1037
1038fn failed_rule_summary(reports: &[FileReport]) -> Vec<FailedRuleSummary> {
1039 use std::collections::BTreeMap;
1040 let mut by_rule: BTreeMap<(String, RuleSeverity), usize> = BTreeMap::new();
1041 for report in reports {
1042 for violation in &report.violations {
1043 *by_rule
1044 .entry((violation.rule_id.clone(), violation.severity))
1045 .or_default() += 1;
1046 }
1047 }
1048 let mut out: Vec<_> = by_rule
1049 .into_iter()
1050 .map(|((rule_id, severity), violations)| FailedRuleSummary {
1051 rule_id,
1052 severity,
1053 violations,
1054 })
1055 .collect();
1056 out.sort_by(|a, b| {
1057 b.violations
1058 .cmp(&a.violations)
1059 .then_with(|| b.severity.cmp(&a.severity))
1060 .then_with(|| a.rule_id.cmp(&b.rule_id))
1061 });
1062 out
1063}