1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, 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::code::{LocalCodeIndex, LocalCodeIndexOptions};
9use code_moniker_workspace::environment;
10use code_moniker_workspace::lang::path_to_lang;
11use code_moniker_workspace::linkage::{LinkagePort, LocalLinkage};
12use code_moniker_workspace::registry::{LocalWorkspaceOptions, LocalWorkspaceRegistry};
13use code_moniker_workspace::snapshot::{
14 ChangeOverlay, RecordTable, ResourceGeneration, SourceFileRecord, SourceId,
15 SymbolInventoryIndex, SymbolSet, WorkspaceRequest, WorkspaceSnapshot, WorkspaceTimings,
16 WorkspaceTransition,
17};
18use code_moniker_workspace::source::{
19 CodeIndexMaterial, IndexedSourceFile, LocalIdentityResolver, LocalResourceCache,
20};
21
22use crate::check;
23use crate::check::config::{self, RuleSeverity};
24use crate::check::eval::CompiledRuleSpec;
25use crate::check::expr::Domain;
26
27#[derive(Clone, Debug)]
30pub struct FileReport {
31 pub path: PathBuf,
32 pub violations: Vec<check::Violation>,
33 pub rule_reports: Vec<check::RuleReport>,
34}
35
36#[derive(Clone, Debug)]
39pub struct FileError {
40 pub path: PathBuf,
41 pub error: String,
42}
43
44pub trait CheckWorkspace: Sync {
45 fn is_dir(&self, path: &Path) -> anyhow::Result<bool>;
46 fn read_to_string(&self, path: &Path) -> anyhow::Result<String>;
47 fn source_graph(
48 &self,
49 file: &environment::SourceFile,
50 ctx: &environment::ExtractContext,
51 ) -> anyhow::Result<(String, CodeGraph)> {
52 let source = self.read_to_string(&file.path)?;
53 let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
54 Ok((source, graph))
55 }
56 fn source_set(
57 &self,
58 root: &Path,
59 files: &[PathBuf],
60 ) -> anyhow::Result<environment::SourceFileSet>;
61 fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet>;
62 fn exists(&self, path: &Path) -> bool;
63 fn linked_snapshot(
64 &self,
65 _source_set: &environment::SourceFileSet,
66 _scheme: &str,
67 ) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>>;
68}
69
70#[derive(Clone, Copy, Debug, Default)]
71pub struct FsCheckWorkspace;
72
73impl CheckWorkspace for FsCheckWorkspace {
74 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
75 let meta = std::fs::metadata(path)
76 .map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
77 Ok(meta.is_dir())
78 }
79
80 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
81 std::fs::read_to_string(path)
82 .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))
83 }
84
85 fn source_set(
86 &self,
87 root: &Path,
88 files: &[PathBuf],
89 ) -> anyhow::Result<environment::SourceFileSet> {
90 if files.is_empty() {
91 environment::discover_sources(&[root.to_path_buf()], None)
92 } else {
93 environment::discover_source_files(root, files, None)
94 }
95 }
96
97 fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
98 environment::discover_source_catalog(root, None)
99 }
100
101 fn exists(&self, path: &Path) -> bool {
102 path.exists()
103 }
104
105 fn linked_snapshot(
106 &self,
107 source_set: &environment::SourceFileSet,
108 scheme: &str,
109 ) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
110 build_fs_linked_snapshot(source_set, scheme).map(Some)
111 }
112}
113
114fn build_fs_linked_snapshot(
115 source_set: &environment::SourceFileSet,
116 scheme: &str,
117) -> anyhow::Result<Arc<WorkspaceSnapshot>> {
118 let paths = source_set
119 .roots
120 .iter()
121 .map(|root| root.input.clone())
122 .collect();
123 let options =
124 LocalWorkspaceOptions::new(paths, None).with_identity(LocalIdentityResolver::new(scheme));
125 let mut registry = LocalWorkspaceRegistry::local(options);
126 match registry
127 .commands()
128 .refresh(WorkspaceRequest::new("check-workspace-linkage"))
129 {
130 WorkspaceTransition::Ready { .. } => registry
131 .queries()
132 .snapshot_arc()
133 .ok_or_else(|| anyhow::anyhow!("workspace refresh completed without a snapshot")),
134 WorkspaceTransition::Failed { failure, .. } => {
135 anyhow::bail!("workspace linkage build failed: {}", failure.message)
136 }
137 }
138}
139
140#[derive(Clone)]
141pub struct IndexedCheckWorkspace {
142 root: PathBuf,
143 material: Arc<CodeIndexMaterial>,
144 snapshot: Arc<WorkspaceSnapshot>,
145}
146
147impl IndexedCheckWorkspace {
148 pub fn from_snapshot(
149 root: impl Into<PathBuf>,
150 cache: &LocalResourceCache,
151 snapshot: Arc<WorkspaceSnapshot>,
152 ) -> anyhow::Result<Self> {
153 let material = indexed_material_for_snapshot(cache, &snapshot)?;
154 Ok(Self {
155 root: root.into(),
156 material,
157 snapshot,
158 })
159 }
160}
161
162fn indexed_material_for_snapshot(
163 cache: &LocalResourceCache,
164 snapshot: &WorkspaceSnapshot,
165) -> anyhow::Result<Arc<CodeIndexMaterial>> {
166 if snapshot.linkage.index_generation != snapshot.index.generation {
167 anyhow::bail!(
168 "indexed snapshot generation mismatch: linkage uses {}, index uses {}",
169 snapshot.linkage.index_generation.value(),
170 snapshot.index.generation.value()
171 );
172 }
173 let material = cache
174 .index_material(snapshot.index.generation)
175 .ok_or_else(|| {
176 anyhow::anyhow!(
177 "indexed source material is unavailable for generation {}",
178 snapshot.index.generation.value()
179 )
180 })?;
181 if material.identity.scheme() != snapshot.index.identity_scheme {
182 anyhow::bail!(
183 "indexed snapshot scheme mismatch: material uses {}, index uses {}",
184 material.identity.scheme(),
185 snapshot.index.identity_scheme
186 );
187 }
188 Ok(material)
189}
190
191fn indexed_file<'a>(
192 material: &'a CodeIndexMaterial,
193 root: &Path,
194 path: &Path,
195) -> Option<&'a IndexedSourceFile> {
196 let absolute = if path.is_absolute() {
197 path.to_path_buf()
198 } else {
199 root.join(path)
200 };
201 material.files.iter().find_map(|file| {
202 (file.path == path || file.path == absolute || file.rel_path == path || file.anchor == path)
203 .then_some(file.as_ref())
204 })
205}
206
207fn indexed_source_file<'a>(
208 material: &'a CodeIndexMaterial,
209 file: &environment::SourceFile,
210) -> Option<&'a IndexedSourceFile> {
211 let file_idx = material.source_set().files.iter().position(|candidate| {
212 candidate.source == file.source
213 && candidate.path == file.path
214 && candidate.rel_path == file.rel_path
215 && candidate.anchor == file.anchor
216 && candidate.lang == file.lang
217 })?;
218 let indexed = material.files.get(file_idx)?;
219 (indexed.source_root == file.source).then_some(indexed)
220}
221
222fn indexed_file_selected(
223 source_set: &environment::SourceFileSet,
224 root: &Path,
225 file: &environment::SourceFile,
226 requested: &[PathBuf],
227) -> bool {
228 if file.retired
229 || source_set
230 .roots
231 .get(file.source)
232 .is_none_or(|source_root| source_root.input != root)
233 {
234 return false;
235 }
236 if requested.is_empty() {
237 return true;
238 }
239 requested.iter().any(|path| {
240 let absolute = if path.is_absolute() {
241 path.clone()
242 } else {
243 root.join(path)
244 };
245 file.path == *path
246 || file.path == absolute
247 || file.rel_path == *path
248 || file.anchor == *path
249 })
250}
251
252fn ensure_indexed_root(expected: &Path, actual: &Path) -> anyhow::Result<()> {
253 if actual == expected {
254 return Ok(());
255 }
256 anyhow::bail!(
257 "indexed workspace root mismatch: expected {}, got {}",
258 expected.display(),
259 actual.display()
260 );
261}
262
263impl CheckWorkspace for IndexedCheckWorkspace {
264 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
265 Ok(path == self.root)
266 }
267
268 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
269 indexed_file(&self.material, &self.root, path)
270 .map(|file| file.source.clone())
271 .ok_or_else(|| anyhow::anyhow!("cannot read {}: not indexed", path.display()))
272 }
273
274 fn source_graph(
275 &self,
276 file: &environment::SourceFile,
277 _ctx: &environment::ExtractContext,
278 ) -> anyhow::Result<(String, CodeGraph)> {
279 let indexed = indexed_source_file(&self.material, file)
280 .ok_or_else(|| anyhow::anyhow!("cannot read {}: not indexed", file.path.display()))?;
281 Ok((indexed.source.clone(), indexed.graph.clone()))
282 }
283
284 fn source_set(
285 &self,
286 root: &Path,
287 files: &[PathBuf],
288 ) -> anyhow::Result<environment::SourceFileSet> {
289 ensure_indexed_root(&self.root, root)?;
290 let source_set = self.material.source_set();
291 Ok(environment::SourceFileSet {
292 roots: source_set.roots.clone(),
293 files: source_set
294 .files
295 .iter()
296 .filter(|file| indexed_file_selected(source_set, &self.root, file, files))
297 .cloned()
298 .collect(),
299 multi: source_set.multi,
300 })
301 }
302
303 fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
304 self.source_set(root, &[])
305 }
306
307 fn exists(&self, path: &Path) -> bool {
308 indexed_file(&self.material, &self.root, path).is_some()
309 }
310
311 fn linked_snapshot(
312 &self,
313 _source_set: &environment::SourceFileSet,
314 scheme: &str,
315 ) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
316 if scheme != self.snapshot.index.identity_scheme {
317 anyhow::bail!(
318 "indexed workspace scheme mismatch: expected {}, got {scheme}",
319 self.snapshot.index.identity_scheme
320 );
321 }
322 Ok(Some(Arc::clone(&self.snapshot)))
323 }
324}
325
326#[derive(Clone, Debug)]
327pub struct MemoryCheckWorkspace {
328 root: PathBuf,
329 files: BTreeMap<PathBuf, MemorySourceFile>,
330}
331
332#[derive(Clone, Debug)]
333struct MemorySourceFile {
334 body: String,
335 lang: Lang,
336}
337
338impl MemoryCheckWorkspace {
339 pub fn new(root: impl Into<PathBuf>) -> Self {
340 Self {
341 root: root.into(),
342 files: BTreeMap::new(),
343 }
344 }
345
346 pub fn with_file(
347 mut self,
348 path: impl Into<PathBuf>,
349 body: impl Into<String>,
350 lang: Lang,
351 ) -> Self {
352 self.files.insert(
353 normalize_relative(path.into()),
354 MemorySourceFile {
355 body: body.into(),
356 lang,
357 },
358 );
359 self
360 }
361
362 pub fn root(&self) -> &Path {
363 &self.root
364 }
365}
366
367impl CheckWorkspace for MemoryCheckWorkspace {
368 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
369 Ok(path == self.root || path == Path::new("."))
370 }
371
372 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
373 let rel = memory_rel_path(&self.root, path);
374 self.files
375 .get(&rel)
376 .map(|file| file.body.clone())
377 .ok_or_else(|| anyhow::anyhow!("cannot read {}: not found", path.display()))
378 }
379
380 fn source_set(
381 &self,
382 root: &Path,
383 files: &[PathBuf],
384 ) -> anyhow::Result<environment::SourceFileSet> {
385 ensure_memory_root(&self.root, root)?;
386 Ok(environment::SourceFileSet {
387 roots: vec![memory_source_root(&self.root)],
388 files: memory_source_files(&self.root, &self.files, files),
389 multi: false,
390 })
391 }
392
393 fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
394 self.source_set(root, &[])
395 }
396
397 fn exists(&self, path: &Path) -> bool {
398 let rel = memory_rel_path(&self.root, path);
399 self.files.contains_key(&rel)
400 }
401
402 fn linked_snapshot(
403 &self,
404 source_set: &environment::SourceFileSet,
405 scheme: &str,
406 ) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
407 build_memory_linked_snapshot(self, source_set, scheme).map(Some)
408 }
409}
410
411fn ensure_memory_root(expected: &Path, actual: &Path) -> anyhow::Result<()> {
412 if actual == expected {
413 return Ok(());
414 }
415 anyhow::bail!(
416 "memory workspace root mismatch: expected {}, got {}",
417 expected.display(),
418 actual.display()
419 );
420}
421
422fn build_memory_linked_snapshot(
423 workspace: &MemoryCheckWorkspace,
424 source_set: &environment::SourceFileSet,
425 scheme: &str,
426) -> anyhow::Result<Arc<WorkspaceSnapshot>> {
427 let identity = LocalIdentityResolver::new(scheme);
428 let files = memory_indexed_files(workspace, source_set, &identity)?;
429 let cache = LocalResourceCache::default();
430 let mut code_index = LocalCodeIndex::new(LocalCodeIndexOptions::default(), cache.clone());
431 let (catalog, index) = code_index
432 .build_index_from_extracted(source_set.clone(), identity, files)
433 .map_err(|failure| anyhow::anyhow!(failure.message))?;
434 let mut linker = LocalLinkage::new(cache);
435 let linkage = linker
436 .resolve_linkage(&index)
437 .map_err(|failure| anyhow::anyhow!(failure.message))?;
438 let changes = ChangeOverlay::new(
439 catalog.generation,
440 catalog.generation,
441 index.generation,
442 Vec::new(),
443 );
444 Ok(Arc::new(WorkspaceSnapshot {
445 generation: linkage.generation,
446 catalog,
447 index,
448 linkage,
449 changes,
450 timings: WorkspaceTimings::default(),
451 }))
452}
453
454fn memory_indexed_files(
455 workspace: &MemoryCheckWorkspace,
456 source_set: &environment::SourceFileSet,
457 identity: &LocalIdentityResolver,
458) -> anyhow::Result<Vec<IndexedSourceFile>> {
459 source_set
460 .files
461 .iter()
462 .enumerate()
463 .map(|(file_idx, file)| {
464 let source = workspace
465 .files
466 .get(&file.rel_path)
467 .ok_or_else(|| anyhow::anyhow!("cannot read {}: not found", file.path.display()))?;
468 let root = source_set
469 .roots
470 .get(file.source)
471 .ok_or_else(|| anyhow::anyhow!("source root {} is unavailable", file.source))?;
472 let ctx = file.extraction_context(root);
473 Ok(IndexedSourceFile {
474 source_root: file.source,
475 source_id: identity.source_id(file_idx, &file.rel_path),
476 source_uri: identity.source_uri(&file.rel_path),
477 identity: LocalIdentityResolver::new(identity.scheme()),
478 path: file.path.to_path_buf(),
479 rel_path: file.rel_path.to_path_buf(),
480 anchor: file.anchor.to_path_buf(),
481 lang: file.lang,
482 graph: environment::extract_source_with(
483 file.lang,
484 &source.body,
485 &file.anchor,
486 &ctx,
487 ),
488 source: source.body.to_owned(),
489 extraction_cache: "provided",
490 extraction_duration: std::time::Duration::ZERO,
491 })
492 })
493 .collect()
494}
495
496fn memory_rel_path(root: &Path, path: &Path) -> PathBuf {
497 normalize_relative(path.strip_prefix(root).unwrap_or(path).to_path_buf())
498}
499
500fn memory_source_root(root: &Path) -> environment::SourceRoot {
501 environment::SourceRoot {
502 input: root.to_path_buf(),
503 path: root.to_path_buf(),
504 label: ".".to_string(),
505 ctx: environment::ExtractContext::default(),
506 source_groups: Default::default(),
507 }
508}
509
510fn memory_source_files(
511 root: &Path,
512 files: &BTreeMap<PathBuf, MemorySourceFile>,
513 requested: &[PathBuf],
514) -> Vec<environment::SourceFile> {
515 files
516 .iter()
517 .filter(|(path, _)| memory_file_selected(root, path, requested))
518 .map(|(rel_path, file)| environment::SourceFile {
519 source: 0,
520 path: root.join(rel_path),
521 rel_path: rel_path.clone(),
522 anchor: rel_path.clone(),
523 lang: file.lang,
524 root_moniker: environment::source_root_moniker(
525 file.lang,
526 rel_path,
527 &environment::ExtractContext::default(),
528 ),
529 source_group: None,
530 srcset: None,
531 retired: false,
532 })
533 .collect()
534}
535
536fn memory_file_selected(root: &Path, path: &Path, requested: &[PathBuf]) -> bool {
537 requested.is_empty()
538 || requested.iter().any(|candidate| {
539 let candidate = normalize_relative(candidate.clone());
540 candidate == path || normalize_relative(root.join(&candidate)) == path
541 })
542}
543
544#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
546pub enum DefaultRulesSelection {
547 #[default]
548 Config,
549 Enabled,
550 Disabled,
551}
552
553impl DefaultRulesSelection {
554 pub fn from_override(value: Option<bool>) -> Self {
555 match value {
556 Some(true) => Self::Enabled,
557 Some(false) => Self::Disabled,
558 None => Self::Config,
559 }
560 }
561
562 pub fn as_override(self) -> Option<bool> {
563 match self {
564 Self::Config => None,
565 Self::Enabled => Some(true),
566 Self::Disabled => Some(false),
567 }
568 }
569}
570
571#[derive(Clone, Debug, Eq, PartialEq)]
573pub struct RuleSetRequest {
574 pub rules: Option<PathBuf>,
575 pub inline_rules: Vec<String>,
576 pub default_rules: DefaultRulesSelection,
577 pub profile: Option<String>,
578 pub scheme: String,
579 pub project_root: Option<PathBuf>,
580}
581
582impl RuleSetRequest {
583 pub fn new(rules: Option<PathBuf>, scheme: impl Into<String>) -> Self {
584 Self {
585 rules,
586 inline_rules: Vec::new(),
587 default_rules: DefaultRulesSelection::Config,
588 profile: None,
589 scheme: scheme.into(),
590 project_root: None,
591 }
592 }
593
594 pub fn with_rules(rules: impl Into<PathBuf>, scheme: impl Into<String>) -> Self {
595 Self::new(Some(rules.into()), scheme)
596 }
597
598 pub fn with_default_rules(mut self, default_rules: DefaultRulesSelection) -> Self {
599 self.default_rules = default_rules;
600 self
601 }
602
603 pub fn with_inline_rules(mut self, inline_rules: Vec<String>) -> Self {
604 self.inline_rules = inline_rules;
605 self
606 }
607
608 pub fn with_profile(mut self, profile: Option<String>) -> Self {
609 self.profile = profile;
610 self
611 }
612
613 pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
614 self.project_root = Some(project_root.into());
615 self
616 }
617
618 pub fn rules_path(&self) -> Option<&Path> {
619 self.rules.as_deref()
620 }
621
622 pub fn scheme(&self) -> &str {
623 &self.scheme
624 }
625
626 pub fn load_config(&self) -> anyhow::Result<check::Config> {
627 let mut cfg = if let Some(project_root) = &self.project_root {
628 config::load_project_with_cli_sources(
629 project_root,
630 self.rules_path(),
631 &self.inline_rules,
632 self.default_rules.as_override(),
633 )?
634 } else {
635 config::load_with_cli_sources(
636 self.rules_path(),
637 &self.inline_rules,
638 self.default_rules.as_override(),
639 )?
640 };
641 if let Some(profile) = &self.profile {
642 cfg.apply_profile(profile)?;
643 }
644 Ok(cfg)
645 }
646
647 pub fn compiled_specs_for_langs(
648 &self,
649 langs: impl IntoIterator<Item = Lang>,
650 ) -> anyhow::Result<Vec<CompiledRuleSpec>> {
651 let cfg = self.load_config()?;
652 compiled_specs_with_config(&cfg, langs, &self.scheme)
653 }
654
655 pub fn check_source(
656 &self,
657 source: &str,
658 anchor: &Path,
659 lang: Lang,
660 report: bool,
661 ) -> anyhow::Result<SourceReport> {
662 let cfg = self.load_config()?;
663 check_source_with_config(&cfg, source, anchor, lang, &self.scheme, report)
664 }
665}
666
667#[derive(Clone, Debug, Eq, PartialEq)]
670pub struct CheckRequest {
671 pub path: PathBuf,
672 pub rules: RuleSetRequest,
673 pub report: bool,
674 pub files: Vec<PathBuf>,
675}
676
677impl CheckRequest {
678 pub fn new(path: impl Into<PathBuf>, rules: RuleSetRequest) -> Self {
679 let path = path.into();
680 Self {
681 rules: rules.with_project_root(path.clone()),
682 path,
683 report: false,
684 files: Vec::new(),
685 }
686 }
687
688 pub fn with_report(mut self, report: bool) -> Self {
689 self.report = report;
690 self
691 }
692
693 pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
694 self.files = files;
695 self
696 }
697
698 pub fn run(&self) -> anyhow::Result<CheckRun> {
699 self.run_with_workspace(&FsCheckWorkspace)
700 }
701
702 pub fn run_with_workspace(&self, workspace: &dyn CheckWorkspace) -> anyhow::Result<CheckRun> {
703 let started = Instant::now();
704 let cfg = self.rules.load_config()?;
705 let (reports, errors, skip_reason) = if workspace.is_dir(&self.path)? {
706 self.run_directory(&cfg, workspace)?
707 } else {
708 self.run_single_file(&cfg, workspace)?
709 };
710 Ok(CheckRun {
711 reports,
712 errors,
713 elapsed_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
714 skip_reason,
715 })
716 }
717
718 fn run_directory(
719 &self,
720 cfg: &check::Config,
721 workspace: &dyn CheckWorkspace,
722 ) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
723 let (reports, errors) = if self.files.is_empty() {
724 check_project_workspace(&self.path, cfg, self.rules.scheme(), self.report, workspace)?
725 } else {
726 check_project_files_workspace(
727 &self.path,
728 &self.files,
729 cfg,
730 self.rules.scheme(),
731 self.report,
732 workspace,
733 )?
734 };
735 let skip_reason = if !self.files.is_empty() && reports.is_empty() && errors.is_empty() {
736 Some(CheckSkipReason::NoMatchingFiles)
737 } else {
738 None
739 };
740 Ok((reports, errors, skip_reason))
741 }
742
743 fn run_single_file(
744 &self,
745 cfg: &check::Config,
746 workspace: &dyn CheckWorkspace,
747 ) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
748 if !self.files.is_empty() {
749 anyhow::bail!("--file can only be used when check PATH is a directory");
750 }
751 let excluded = path_excluded(&self.path, cfg);
752 match check_one_file_workspace(
753 &self.path,
754 cfg,
755 self.rules.scheme(),
756 self.report,
757 workspace,
758 )? {
759 Some(report) => Ok((vec![report], Vec::new(), None)),
760 None if excluded => Ok((
761 Vec::new(),
762 Vec::new(),
763 Some(CheckSkipReason::ExcludedSingleFile),
764 )),
765 None => Ok((
766 Vec::new(),
767 Vec::new(),
768 Some(CheckSkipReason::UnsupportedSingleFile),
769 )),
770 }
771 }
772}
773
774#[derive(Clone, Copy, Debug, Eq, PartialEq)]
777pub enum CheckSkipReason {
778 ExcludedSingleFile,
779 UnsupportedSingleFile,
780 NoMatchingFiles,
781}
782
783#[derive(Clone, Debug)]
786pub struct CheckRun {
787 pub reports: Vec<FileReport>,
788 pub errors: Vec<FileError>,
789 pub elapsed_ms: u64,
790 pub skip_reason: Option<CheckSkipReason>,
791}
792
793impl CheckRun {
794 pub fn any_error_violation(&self) -> bool {
795 self.reports.iter().any(|report| {
796 report
797 .violations
798 .iter()
799 .any(|violation| violation.severity.is_error())
800 })
801 }
802
803 pub fn any_error(&self) -> bool {
804 !self.errors.is_empty()
805 }
806
807 pub fn violation_counts(&self) -> ViolationCounts {
808 violation_counts(&self.reports)
809 }
810
811 pub fn summary(&self) -> CheckSummary {
812 let counts = self.violation_counts();
813 CheckSummary {
814 files_scanned: self.reports.len(),
815 files_with_violations: counts.files_with,
816 total_violations: counts.total,
817 total_rule_errors: counts.errors,
818 total_warnings: counts.warnings,
819 files_with_errors: self.errors.len(),
820 total_errors: self.errors.len(),
821 elapsed_ms: self.elapsed_ms,
822 failed_rules: self.failed_rule_summary(),
823 violations_by_srcset: self.violations_by_srcset(),
824 }
825 }
826
827 pub fn failed_rule_summary(&self) -> Vec<FailedRuleSummary> {
828 failed_rule_summary(&self.reports)
829 }
830
831 pub fn violations_by_srcset(&self) -> std::collections::BTreeMap<String, usize> {
832 violations_by_srcset(&self.reports)
833 }
834
835 pub fn file_violations(&self) -> impl Iterator<Item = (&Path, &check::eval::Violation)> {
836 self.reports.iter().flat_map(|report| {
837 report
838 .violations
839 .iter()
840 .map(move |violation| (report.path.as_path(), violation))
841 })
842 }
843
844 pub fn error_summaries(&self) -> impl Iterator<Item = (&Path, &str)> {
845 self.errors
846 .iter()
847 .map(|error| (error.path.as_path(), error.error.as_str()))
848 }
849
850 pub fn rule_violation_totals(&self) -> std::collections::BTreeMap<&str, usize> {
851 let mut totals = std::collections::BTreeMap::new();
852 for report in &self.reports {
853 for rule in &report.rule_reports {
854 *totals.entry(rule.rule_id.as_str()).or_insert(0usize) += rule.violations;
855 }
856 }
857 totals
858 }
859}
860
861#[derive(Clone, Debug, serde::Serialize)]
863pub struct CheckSummary {
864 pub files_scanned: usize,
865 pub files_with_violations: usize,
866 pub total_violations: usize,
867 pub total_rule_errors: usize,
868 pub total_warnings: usize,
869 pub files_with_errors: usize,
870 pub total_errors: usize,
871 pub elapsed_ms: u64,
872 pub failed_rules: Vec<FailedRuleSummary>,
873 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
874 pub violations_by_srcset: std::collections::BTreeMap<String, usize>,
875}
876
877#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
879pub struct FailedRuleSummary {
880 pub rule_id: String,
881 pub severity: RuleSeverity,
882 pub violations: usize,
883}
884
885#[derive(Clone, Debug, Default, Eq, PartialEq)]
887pub struct ViolationCounts {
888 pub total: usize,
889 pub errors: usize,
890 pub warnings: usize,
891 pub files_with: usize,
892}
893
894#[derive(Clone, Debug)]
896pub struct SourceReport {
897 pub rules: Vec<CompiledRuleSpec>,
898 pub violations: Vec<check::Violation>,
899 pub rule_reports: Vec<check::RuleReport>,
900}
901
902pub fn check_source_with_config(
903 cfg: &check::Config,
904 source: &str,
905 anchor: &Path,
906 lang: Lang,
907 scheme: &str,
908 report: bool,
909) -> anyhow::Result<SourceReport> {
910 let graph = environment::extract_source_with(
911 lang,
912 source,
913 anchor,
914 &environment::ExtractContext::default(),
915 );
916 check_graph_with_config(cfg, &graph, source, lang, scheme, report)
917}
918
919pub fn check_graph_with_config(
920 cfg: &check::Config,
921 graph: &CodeGraph,
922 source: &str,
923 lang: Lang,
924 scheme: &str,
925 report: bool,
926) -> anyhow::Result<SourceReport> {
927 let compiled = check::compile_rules(cfg, lang, scheme)?;
928 let raw = check::evaluate_compiled(graph, source, lang, scheme, &compiled);
929 let violations = check::apply_suppressions(graph, source, raw);
930 let rule_reports = if report {
931 let mut rule_reports = check::rule_report_compiled(graph, source, lang, scheme, &compiled);
932 align_report_violations_with_suppressions(&mut rule_reports, &violations);
933 rule_reports
934 } else {
935 Vec::new()
936 };
937 Ok(SourceReport {
938 rules: compiled.specs(lang),
939 violations,
940 rule_reports,
941 })
942}
943
944pub fn compiled_specs_with_config(
945 cfg: &check::Config,
946 langs: impl IntoIterator<Item = Lang>,
947 scheme: &str,
948) -> anyhow::Result<Vec<CompiledRuleSpec>> {
949 let mut specs = crate::check::workspace_eval::compile_workspace_rules(cfg, scheme)?.specs();
950 for lang in langs {
951 let compiled = check::compile_rules(cfg, lang, scheme)?;
952 specs.extend(compiled.specs(lang));
953 }
954 specs.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
955 Ok(specs)
956}
957
958pub fn check_one_file(
959 path: &Path,
960 cfg: &check::Config,
961 scheme: &str,
962 report: bool,
963) -> anyhow::Result<Option<FileReport>> {
964 check_one_file_workspace(path, cfg, scheme, report, &FsCheckWorkspace)
965}
966
967pub fn check_one_file_workspace(
968 path: &Path,
969 cfg: &check::Config,
970 scheme: &str,
971 report: bool,
972 workspace: &dyn CheckWorkspace,
973) -> anyhow::Result<Option<FileReport>> {
974 let Ok(lang) = path_to_lang(path) else {
975 return Ok(None);
976 };
977 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
978 if excludes.matches_path(path) {
979 return Ok(None);
980 }
981 let compiled = check::compile_rules(cfg, lang, scheme)?;
982 let ctx = CompiledCheck {
983 scheme,
984 compiled: &compiled,
985 report,
986 workspace,
987 requirements: None,
988 };
989 check_one_compiled(path, None, lang, &ctx).map(Some)
990}
991
992struct CompiledCheck<'a> {
993 scheme: &'a str,
994 compiled: &'a check::CompiledRules,
995 report: bool,
996 workspace: &'a dyn CheckWorkspace,
997 requirements: Option<&'a dyn check::RequirementResolver>,
998}
999
1000fn check_one_compiled(
1004 fs_path: &Path,
1005 moniker_anchor: Option<&Path>,
1006 lang: code_moniker_core::lang::Lang,
1007 ctx: &CompiledCheck<'_>,
1008) -> anyhow::Result<FileReport> {
1009 let source = ctx.workspace.read_to_string(fs_path)?;
1010 let graph = environment::extract_source_with(
1011 lang,
1012 &source,
1013 moniker_anchor.unwrap_or(fs_path),
1014 &environment::ExtractContext::default(),
1015 );
1016 let raw = check::evaluate_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
1017 let violations = check::apply_suppressions(&graph, &source, raw);
1018 let rule_reports = if ctx.report {
1019 let mut rule_reports =
1020 check::rule_report_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
1021 align_report_violations_with_suppressions(&mut rule_reports, &violations);
1022 rule_reports
1023 } else {
1024 Vec::new()
1025 };
1026 Ok(FileReport {
1027 path: fs_path.to_path_buf(),
1028 violations,
1029 rule_reports,
1030 })
1031}
1032
1033struct CheckedSourceFile {
1034 file: environment::SourceFile,
1035 source: String,
1036 graph: CodeGraph,
1037 report: FileReport,
1038}
1039
1040fn check_source_file_compiled(
1041 file: &environment::SourceFile,
1042 ctx: &environment::ExtractContext,
1043 check_ctx: &CompiledCheck<'_>,
1044) -> anyhow::Result<CheckedSourceFile> {
1045 let (source, graph) = check_ctx.workspace.source_graph(file, ctx)?;
1046 let raw = check::evaluate_compiled_with_requirements(
1047 &graph,
1048 &source,
1049 file.lang,
1050 check_ctx.scheme,
1051 check_ctx.compiled,
1052 check_ctx.requirements,
1053 );
1054 let violations = check::apply_suppressions(&graph, &source, raw);
1055 let rule_reports = if check_ctx.report {
1056 let mut rule_reports = check::rule_report_compiled_with_requirements(
1057 &graph,
1058 &source,
1059 file.lang,
1060 check_ctx.scheme,
1061 check_ctx.compiled,
1062 check_ctx.requirements,
1063 );
1064 align_report_violations_with_suppressions(&mut rule_reports, &violations);
1065 rule_reports
1066 } else {
1067 Vec::new()
1068 };
1069 Ok(CheckedSourceFile {
1070 file: file.clone(),
1071 source,
1072 graph,
1073 report: FileReport {
1074 path: file.path.clone(),
1075 violations,
1076 rule_reports,
1077 },
1078 })
1079}
1080
1081pub fn check_project(
1085 root: &Path,
1086 cfg: &check::Config,
1087 scheme: &str,
1088 report: bool,
1089) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1090 check_project_workspace(root, cfg, scheme, report, &FsCheckWorkspace)
1091}
1092
1093pub fn check_project_workspace(
1094 root: &Path,
1095 cfg: &check::Config,
1096 scheme: &str,
1097 report: bool,
1098 workspace: &dyn CheckWorkspace,
1099) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1100 let source_set = workspace.source_set(root, &[])?;
1101 let requirements = FileRequirementResolver::new(
1102 root.to_path_buf(),
1103 filtered_source_set(&source_set, cfg),
1104 &cfg.exclude.uris,
1105 workspace,
1106 );
1107 check_source_set(
1108 &source_set,
1109 cfg,
1110 scheme,
1111 Some(&requirements),
1112 workspace,
1113 SourceSetCheckMode {
1114 report,
1115 workspace_rules: true,
1116 },
1117 )
1118}
1119
1120pub fn check_project_files(
1121 root: &Path,
1122 files: &[PathBuf],
1123 cfg: &check::Config,
1124 scheme: &str,
1125 report: bool,
1126) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1127 check_project_files_workspace(root, files, cfg, scheme, report, &FsCheckWorkspace)
1128}
1129
1130pub fn check_project_files_workspace(
1131 root: &Path,
1132 files: &[PathBuf],
1133 cfg: &check::Config,
1134 scheme: &str,
1135 report: bool,
1136 workspace: &dyn CheckWorkspace,
1137) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1138 let source_set = workspace.source_set(root, files)?;
1139 let requirements =
1140 FileRequirementResolver::new(root.to_path_buf(), None, &cfg.exclude.uris, workspace);
1141 let (reports, mut errors) = check_source_set(
1142 &source_set,
1143 cfg,
1144 scheme,
1145 Some(&requirements),
1146 workspace,
1147 SourceSetCheckMode {
1148 report,
1149 workspace_rules: false,
1150 },
1151 )?;
1152 if !cfg.workspace.symbol.rules.is_empty()
1153 || !cfg.workspace.group.rules.is_empty()
1154 || !cfg.workspace.path.is_empty()
1155 {
1156 errors.push(FileError {
1157 path: root.to_path_buf(),
1158 error: "workspace rules were not run: a file-scoped check does not provide a complete symbol inventory"
1159 .to_string(),
1160 });
1161 }
1162 if let Some(error) = requirements.source_catalog_error() {
1163 errors.push(FileError {
1164 path: root.to_path_buf(),
1165 error: error.to_string(),
1166 });
1167 errors.sort_by(|a, b| a.path.cmp(&b.path));
1168 }
1169 Ok((reports, errors))
1170}
1171
1172fn filtered_source_set(
1173 source_set: &environment::SourceFileSet,
1174 cfg: &check::Config,
1175) -> environment::SourceFileSet {
1176 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
1177 filter_source_set(source_set, &excludes)
1178}
1179
1180fn filter_source_set(
1181 source_set: &environment::SourceFileSet,
1182 excludes: &check::UriExclusionMatcher,
1183) -> environment::SourceFileSet {
1184 environment::SourceFileSet {
1185 roots: source_set.roots.clone(),
1186 files: source_set
1187 .files
1188 .iter()
1189 .filter(|file| !excludes.matches_path(&file.path))
1190 .cloned()
1191 .collect(),
1192 multi: source_set.multi,
1193 }
1194}
1195
1196#[derive(Clone, Copy)]
1197struct SourceSetCheckMode {
1198 report: bool,
1199 workspace_rules: bool,
1200}
1201
1202fn check_source_set(
1203 source_set: &environment::SourceFileSet,
1204 cfg: &check::Config,
1205 scheme: &str,
1206 requirements: Option<&dyn check::RequirementResolver>,
1207 workspace: &dyn CheckWorkspace,
1208 mode: SourceSetCheckMode,
1209) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1210 use rayon::prelude::*;
1211 use std::collections::HashMap;
1212 let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
1213 let mut compiled: HashMap<code_moniker_core::lang::Lang, check::CompiledRules> = HashMap::new();
1214 let files: Vec<&environment::SourceFile> = source_set
1215 .files
1216 .iter()
1217 .filter(|f| !excludes.matches_path(&f.path))
1218 .collect();
1219 for f in &files {
1220 if compiled.contains_key(&f.lang) {
1221 continue;
1222 }
1223 compiled.insert(f.lang, check::compile_rules(cfg, f.lang, scheme)?);
1224 }
1225 let outcomes: Vec<Result<CheckedSourceFile, FileError>> = files
1226 .par_iter()
1227 .map(|f| {
1228 let f = *f;
1229 let rules = &compiled[&f.lang];
1230 let ctx = &source_set.roots[f.source].ctx;
1231 let check_ctx = CompiledCheck {
1232 scheme,
1233 compiled: rules,
1234 report: mode.report,
1235 workspace,
1236 requirements,
1237 };
1238 check_source_file_compiled(f, ctx, &check_ctx).map_err(|e| FileError {
1239 path: f.path.clone(),
1240 error: format!("{e:#}"),
1241 })
1242 })
1243 .collect();
1244 let mut checked = Vec::new();
1245 let mut errors = Vec::new();
1246 for o in outcomes {
1247 match o {
1248 Ok(r) => checked.push(r),
1249 Err(e) => errors.push(e),
1250 }
1251 }
1252 checked.sort_by(|a, b| a.report.path.cmp(&b.report.path));
1253 if mode.workspace_rules && errors.is_empty() {
1254 apply_workspace_rules(
1255 &mut checked,
1256 source_set,
1257 cfg,
1258 scheme,
1259 mode.report,
1260 workspace,
1261 )?;
1262 }
1263 let reports = checked.into_iter().map(|checked| checked.report).collect();
1264 errors.sort_by(|a, b| a.path.cmp(&b.path));
1265 Ok((reports, errors))
1266}
1267
1268fn apply_workspace_rules(
1269 checked: &mut [CheckedSourceFile],
1270 source_set: &environment::SourceFileSet,
1271 cfg: &check::Config,
1272 scheme: &str,
1273 report: bool,
1274 workspace: &dyn CheckWorkspace,
1275) -> anyhow::Result<()> {
1276 let compiled = crate::check::workspace_eval::compile_workspace_rules(cfg, scheme)?;
1277 if compiled.is_empty() {
1278 return Ok(());
1279 }
1280 let (mut evaluation, source_map) = if compiled.has_linkage_rules() {
1281 let snapshot = workspace
1282 .linked_snapshot(source_set, scheme)?
1283 .ok_or_else(|| {
1284 anyhow::anyhow!(
1285 "workspace linkage rules were not run: this check workspace does not provide a linked snapshot"
1286 )
1287 })?;
1288 let source_map = linked_source_map(&snapshot, checked);
1289 let universe = linked_symbol_universe(&snapshot, &source_map);
1290 let evaluation = crate::check::workspace_eval::evaluate_workspace_rules_linked_in(
1291 &snapshot.index,
1292 &snapshot.linkage,
1293 &universe,
1294 &compiled,
1295 report,
1296 )?;
1297 (evaluation, Some(source_map))
1298 } else {
1299 let generation = ResourceGeneration::new(0);
1300 let sources = workspace_source_records(checked);
1301 let symbols = workspace_symbol_records(checked, scheme);
1302 let inventory = SymbolInventoryIndex::build(generation, &sources, &symbols);
1303 (
1304 crate::check::workspace_eval::evaluate_workspace_rules(&inventory, &compiled, report),
1305 None,
1306 )
1307 };
1308 if let Some(source_map) = &source_map {
1309 evaluation.violations.retain_mut(|violation| {
1310 let Some(checked_idx) = source_map.get(&violation.source.file()) else {
1311 return false;
1312 };
1313 violation.source = SourceId::at(*checked_idx);
1314 true
1315 });
1316 }
1317 let kept_by_rule = merge_workspace_violations(checked, evaluation.violations);
1318 for rule_report in &mut evaluation.reports {
1319 rule_report.violations = kept_by_rule
1320 .get(&rule_report.rule_id)
1321 .copied()
1322 .unwrap_or_default();
1323 if rule_report.verdict.is_some() {
1324 rule_report.verdict = Some(if rule_report.violations > 0 {
1325 crate::RuleVerdict::Fail
1326 } else if rule_report.inconclusive.unwrap_or_default() > 0 {
1327 crate::RuleVerdict::Inconclusive
1328 } else {
1329 crate::RuleVerdict::Pass
1330 });
1331 }
1332 }
1333 if report && let Some(first) = checked.first_mut() {
1334 first.report.rule_reports.extend(evaluation.reports);
1335 first
1336 .report
1337 .rule_reports
1338 .sort_by(|left, right| left.rule_id.cmp(&right.rule_id));
1339 }
1340 Ok(())
1341}
1342
1343fn linked_source_map(
1344 snapshot: &WorkspaceSnapshot,
1345 checked: &[CheckedSourceFile],
1346) -> std::collections::HashMap<usize, usize> {
1347 use std::collections::HashMap;
1348
1349 let by_path = checked
1350 .iter()
1351 .enumerate()
1352 .map(|(idx, checked)| (checked.file.path.clone(), idx))
1353 .collect::<HashMap<_, _>>();
1354 let by_root_relative = checked
1355 .iter()
1356 .enumerate()
1357 .map(|(idx, checked)| ((checked.file.source, checked.file.rel_path.clone()), idx))
1358 .collect::<HashMap<_, _>>();
1359 snapshot
1360 .index
1361 .sources
1362 .iter()
1363 .filter_map(|source| {
1364 let checked_idx = by_path
1365 .get(Path::new(&source.path))
1366 .or_else(|| {
1367 by_root_relative.get(&(source.source_root, PathBuf::from(&source.rel_path)))
1368 })
1369 .copied()?;
1370 Some((source.id.file(), checked_idx))
1371 })
1372 .collect()
1373}
1374
1375fn linked_symbol_universe(
1376 snapshot: &WorkspaceSnapshot,
1377 source_map: &std::collections::HashMap<usize, usize>,
1378) -> SymbolSet {
1379 let mut universe = SymbolSet::new();
1380 for source_idx in source_map.keys() {
1381 if let Some(symbols) = snapshot
1382 .index
1383 .inventory
1384 .facets()
1385 .symbols_by_source(SourceId::at(*source_idx))
1386 {
1387 universe.union_with(symbols);
1388 }
1389 }
1390 universe
1391}
1392
1393fn workspace_source_records(checked: &[CheckedSourceFile]) -> Vec<SourceFileRecord> {
1394 checked
1395 .iter()
1396 .enumerate()
1397 .map(|(file_idx, checked)| SourceFileRecord {
1398 id: SourceId::at(file_idx),
1399 uri: checked.file.path.display().to_string(),
1400 source_root: checked.file.source,
1401 path: checked.file.path.display().to_string(),
1402 rel_path: checked.file.rel_path.display().to_string(),
1403 anchor: checked.file.anchor.display().to_string(),
1404 language: checked.file.lang.tag().to_string(),
1405 text: String::new(),
1406 })
1407 .collect()
1408}
1409
1410fn workspace_symbol_records(
1411 checked: &[CheckedSourceFile],
1412 scheme: &str,
1413) -> RecordTable<code_moniker_workspace::snapshot::SymbolRecord> {
1414 let shards = checked
1415 .iter()
1416 .enumerate()
1417 .map(|(file_idx, checked)| {
1418 Arc::from(environment::symbol_records_for_graph(
1419 file_idx,
1420 SourceId::at(file_idx),
1421 &checked.graph,
1422 &checked.source,
1423 checked.file.lang,
1424 scheme,
1425 ))
1426 })
1427 .collect();
1428 RecordTable::from_shards(shards)
1429}
1430
1431fn merge_workspace_violations(
1432 checked: &mut [CheckedSourceFile],
1433 violations: Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1434) -> BTreeMap<String, usize> {
1435 let mut by_source =
1436 BTreeMap::<usize, Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>>::new();
1437 for workspace_violation in violations {
1438 by_source
1439 .entry(workspace_violation.source.file())
1440 .or_default()
1441 .push(workspace_violation);
1442 }
1443 let mut kept_by_rule = BTreeMap::new();
1444 for (file_idx, workspace_violations) in by_source {
1445 let Some(checked) = checked.get_mut(file_idx) else {
1446 continue;
1447 };
1448 let (suppressible, fixed): (
1449 Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1450 Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1451 ) = workspace_violations
1452 .into_iter()
1453 .partition(|violation| violation.source_suppression);
1454 let mut suppressible = check::apply_suppressions(
1455 &checked.graph,
1456 &checked.source,
1457 suppressible
1458 .into_iter()
1459 .map(|violation| violation.violation)
1460 .collect(),
1461 );
1462 let mut violations = fixed
1463 .into_iter()
1464 .map(|violation| violation.violation)
1465 .collect::<Vec<_>>();
1466 violations.append(&mut suppressible);
1467 for violation in &violations {
1468 *kept_by_rule
1469 .entry(violation.rule_id.to_owned())
1470 .or_insert(0) += 1;
1471 }
1472 checked.report.violations.extend(violations);
1473 checked.report.violations.sort_by(|left, right| {
1474 left.lines
1475 .cmp(&right.lines)
1476 .then_with(|| left.rule_id.cmp(&right.rule_id))
1477 });
1478 }
1479 kept_by_rule
1480}
1481
1482struct FileRequirementResolver<'a> {
1483 root: PathBuf,
1484 source_set: OnceLock<Result<environment::SourceFileSet, String>>,
1485 file_defs: OnceLock<Vec<OnceLock<Vec<DefRecord>>>>,
1486 excludes: check::UriExclusionMatcher,
1487 workspace: &'a dyn CheckWorkspace,
1488}
1489
1490impl<'a> FileRequirementResolver<'a> {
1491 fn new(
1492 root: PathBuf,
1493 source_set: impl Into<Option<environment::SourceFileSet>>,
1494 exclude_uris: &[String],
1495 workspace: &'a dyn CheckWorkspace,
1496 ) -> Self {
1497 let source_set_cell = OnceLock::new();
1498 if let Some(source_set) = source_set.into() {
1499 source_set_cell
1500 .set(Ok(source_set))
1501 .unwrap_or_else(|_| unreachable!("new source catalog cell"));
1502 }
1503 Self {
1504 root,
1505 source_set: source_set_cell,
1506 file_defs: OnceLock::new(),
1507 excludes: check::UriExclusionMatcher::new(exclude_uris),
1508 workspace,
1509 }
1510 }
1511}
1512
1513impl check::RequirementResolver for FileRequirementResolver<'_> {
1514 fn exists(&self, pattern: &str, _source: &DefRecord, _scheme: &str) -> bool {
1515 let Some(candidates) = source_candidates_from_requirement(&self.root, pattern) else {
1516 return false;
1517 };
1518 let Ok(path_pattern) = check::path::parse(pattern) else {
1519 return false;
1520 };
1521 for path in candidates {
1522 if !self.workspace.exists(&path) {
1523 continue;
1524 }
1525 let Ok(lang) = path_to_lang(&path) else {
1526 continue;
1527 };
1528 let Ok(source) = self.workspace.read_to_string(&path) else {
1529 continue;
1530 };
1531 let graph = environment::extract_source_with(
1532 lang,
1533 &source,
1534 &anchor_for_requirement(&self.root, &path),
1535 &environment::ExtractContext::default(),
1536 );
1537 if graph
1538 .defs()
1539 .any(|def| check::path::matches(&path_pattern, &def.moniker))
1540 {
1541 return true;
1542 }
1543 }
1544 false
1545 }
1546
1547 fn descendant_defs<'a>(&'a self, owner: &DefRecord, inner: &Domain) -> Vec<&'a DefRecord> {
1548 use rayon::prelude::*;
1549
1550 let Ok(source_set) = self.source_set() else {
1551 return Vec::new();
1552 };
1553 let file_defs = self.file_defs(source_set.files.len());
1554 let candidate_indexes = source_set
1555 .files
1556 .iter()
1557 .enumerate()
1558 .filter_map(|(idx, file)| {
1559 file.root_moniker
1560 .as_ref()
1561 .is_some_and(|root| {
1562 root != &owner.moniker && owner.moniker.is_ancestor_of(root)
1563 })
1564 .then_some(idx)
1565 })
1566 .collect::<Vec<_>>();
1567 candidate_indexes.par_iter().for_each(|idx| {
1568 file_defs[*idx].get_or_init(|| {
1569 collect_file_defs(&source_set.files[*idx], source_set, self.workspace)
1570 });
1571 });
1572 candidate_indexes
1573 .into_iter()
1574 .flat_map(|idx| {
1575 file_defs[idx]
1576 .get()
1577 .into_iter()
1578 .flat_map(|defs| defs.iter())
1579 })
1580 .filter(|def| owner.moniker.is_ancestor_of(&def.moniker))
1581 .filter(|def| lazy_domain_matches(inner, def))
1582 .collect()
1583 }
1584}
1585
1586impl FileRequirementResolver<'_> {
1587 fn file_defs(&self, file_count: usize) -> &[OnceLock<Vec<DefRecord>>] {
1588 self.file_defs
1589 .get_or_init(|| (0..file_count).map(|_| OnceLock::new()).collect())
1590 .as_slice()
1591 }
1592
1593 fn source_set(&self) -> Result<&environment::SourceFileSet, &str> {
1594 match self.source_set.get_or_init(|| {
1595 self.workspace
1596 .source_catalog(&self.root)
1597 .map(|source_set| filter_source_set(&source_set, &self.excludes))
1598 .map_err(|error| {
1599 format!(
1600 "cannot build lazy source catalog for `{}`: {error:#}",
1601 self.root.display()
1602 )
1603 })
1604 }) {
1605 Ok(source_set) => Ok(source_set),
1606 Err(error) => Err(error),
1607 }
1608 }
1609
1610 fn source_catalog_error(&self) -> Option<&str> {
1611 self.source_set
1612 .get()
1613 .and_then(|result| result.as_ref().err())
1614 .map(String::as_str)
1615 }
1616}
1617
1618fn collect_file_defs(
1619 file: &environment::SourceFile,
1620 source_set: &environment::SourceFileSet,
1621 workspace: &dyn CheckWorkspace,
1622) -> Vec<DefRecord> {
1623 let Ok(source) = workspace.read_to_string(&file.path) else {
1624 return Vec::new();
1625 };
1626 let ctx = &source_set.roots[file.source].ctx;
1627 let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
1628 if file
1629 .root_moniker
1630 .as_ref()
1631 .is_none_or(|catalog_root| catalog_root != graph.root())
1632 {
1633 return Vec::new();
1634 }
1635 graph.defs().cloned().collect()
1636}
1637
1638fn normalize_relative(path: PathBuf) -> PathBuf {
1639 path.components()
1640 .filter_map(|component| match component {
1641 std::path::Component::Normal(part) => Some(PathBuf::from(part)),
1642 std::path::Component::CurDir => None,
1643 _ => None,
1644 })
1645 .collect()
1646}
1647
1648fn lazy_domain_matches(domain: &Domain, def: &DefRecord) -> bool {
1649 match domain {
1650 Domain::Children(kind) => def.kind.as_ref() == kind.as_bytes(),
1651 Domain::ChildrenByShape(shape) => {
1652 def.shape().is_some_and(|actual| actual.as_str() == shape)
1653 }
1654 Domain::Descendants(inner) => lazy_domain_matches(inner, def),
1655 Domain::Pairs(_)
1656 | Domain::Segments
1657 | Domain::OutRefs
1658 | Domain::InRefs
1659 | Domain::SourceOutRefs
1660 | Domain::SourceInRefs
1661 | Domain::TargetOutRefs
1662 | Domain::TargetInRefs
1663 | Domain::SourceAncestorOutRefs
1664 | Domain::SourceAncestorInRefs => false,
1665 }
1666}
1667
1668fn source_candidates_from_requirement(root: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
1669 let mut dirs = Vec::new();
1670 let mut module = None;
1671 for step in pattern.split('/') {
1672 if let Some(dir) = literal_step_name(step, "dir") {
1673 dirs.push(dir.to_string());
1674 } else if let Some(name) = literal_step_name(step, "module") {
1675 module = Some(name.to_string());
1676 }
1677 }
1678 let module = module?;
1679 let base = dirs
1680 .iter()
1681 .fold(root.to_path_buf(), |path, dir| path.join(dir));
1682 if module == "mod" {
1683 Some(vec![base.join("mod.rs")])
1684 } else {
1685 Some(vec![
1686 base.join(format!("{module}.rs")),
1687 base.join(module).join("mod.rs"),
1688 ])
1689 }
1690}
1691
1692fn literal_step_name<'a>(step: &'a str, kind: &str) -> Option<&'a str> {
1693 let (step_kind, name) = step.split_once(':')?;
1694 (step_kind == kind && !name.contains(['*', '{', '}', '/'])).then_some(name)
1695}
1696
1697fn anchor_for_requirement(root: &Path, path: &Path) -> PathBuf {
1698 path.strip_prefix(root).unwrap_or(path).to_path_buf()
1699}
1700
1701fn align_report_violations_with_suppressions(
1702 rule_reports: &mut [check::RuleReport],
1703 violations: &[check::Violation],
1704) {
1705 use std::collections::HashMap;
1706 let mut counts: HashMap<&str, usize> = HashMap::new();
1707 for v in violations {
1708 *counts.entry(v.rule_id.as_str()).or_insert(0) += 1;
1709 }
1710 for report in rule_reports {
1711 report.violations = counts.get(report.rule_id.as_str()).copied().unwrap_or(0);
1712 }
1713}
1714
1715fn path_excluded(path: &Path, cfg: &check::Config) -> bool {
1716 check::UriExclusionMatcher::new(&cfg.exclude.uris).matches_path(path)
1717}
1718
1719fn violation_counts(reports: &[FileReport]) -> ViolationCounts {
1720 let mut counts = ViolationCounts::default();
1721 for report in reports {
1722 if report.violations.is_empty() {
1723 continue;
1724 }
1725 counts.files_with += 1;
1726 for violation in &report.violations {
1727 counts.total += 1;
1728 if violation.severity.is_error() {
1729 counts.errors += 1;
1730 } else {
1731 counts.warnings += 1;
1732 }
1733 }
1734 }
1735 counts
1736}
1737
1738fn violations_by_srcset(reports: &[FileReport]) -> std::collections::BTreeMap<String, usize> {
1739 let mut counts = std::collections::BTreeMap::new();
1740 let mut unspecified = 0usize;
1741 for report in reports {
1742 for violation in &report.violations {
1743 if let Some(srcset) = &violation.srcset {
1744 *counts.entry(srcset.clone()).or_default() += 1;
1745 } else {
1746 unspecified += 1;
1747 }
1748 }
1749 }
1750 if !counts.is_empty() && unspecified > 0 {
1751 counts.insert("unspecified".to_string(), unspecified);
1752 }
1753 counts
1754}
1755
1756fn failed_rule_summary(reports: &[FileReport]) -> Vec<FailedRuleSummary> {
1757 use std::collections::BTreeMap;
1758 let mut by_rule: BTreeMap<(String, RuleSeverity), usize> = BTreeMap::new();
1759 for report in reports {
1760 for violation in &report.violations {
1761 *by_rule
1762 .entry((violation.rule_id.clone(), violation.severity))
1763 .or_default() += 1;
1764 }
1765 }
1766 let mut out: Vec<_> = by_rule
1767 .into_iter()
1768 .map(|((rule_id, severity), violations)| FailedRuleSummary {
1769 rule_id,
1770 severity,
1771 violations,
1772 })
1773 .collect();
1774 out.sort_by(|a, b| {
1775 b.violations
1776 .cmp(&a.violations)
1777 .then_with(|| b.severity.cmp(&a.severity))
1778 .then_with(|| a.rule_id.cmp(&b.rule_id))
1779 });
1780 out
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785 use super::*;
1786 use std::sync::Mutex;
1787 use std::sync::atomic::{AtomicUsize, Ordering};
1788
1789 struct RecordingWorkspace {
1790 inner: MemoryCheckWorkspace,
1791 reads: Mutex<Vec<PathBuf>>,
1792 catalog_calls: AtomicUsize,
1793 fail_catalog: bool,
1794 }
1795
1796 impl RecordingWorkspace {
1797 fn new(inner: MemoryCheckWorkspace) -> Self {
1798 Self {
1799 inner,
1800 reads: Mutex::new(Vec::new()),
1801 catalog_calls: AtomicUsize::new(0),
1802 fail_catalog: false,
1803 }
1804 }
1805
1806 fn with_catalog_error(mut self) -> Self {
1807 self.fail_catalog = true;
1808 self
1809 }
1810
1811 fn reads(&self) -> Vec<PathBuf> {
1812 self.reads.lock().expect("read log").clone()
1813 }
1814 }
1815
1816 impl CheckWorkspace for RecordingWorkspace {
1817 fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
1818 self.inner.is_dir(path)
1819 }
1820
1821 fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
1822 self.reads
1823 .lock()
1824 .expect("read log")
1825 .push(path.to_path_buf());
1826 self.inner.read_to_string(path)
1827 }
1828
1829 fn source_set(
1830 &self,
1831 root: &Path,
1832 files: &[PathBuf],
1833 ) -> anyhow::Result<environment::SourceFileSet> {
1834 self.inner.source_set(root, files)
1835 }
1836
1837 fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
1838 self.catalog_calls.fetch_add(1, Ordering::Relaxed);
1839 if self.fail_catalog {
1840 anyhow::bail!("catalog unavailable");
1841 }
1842 self.inner.source_catalog(root)
1843 }
1844
1845 fn exists(&self, path: &Path) -> bool {
1846 self.inner.exists(path)
1847 }
1848
1849 fn linked_snapshot(
1850 &self,
1851 source_set: &environment::SourceFileSet,
1852 scheme: &str,
1853 ) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
1854 self.inner.linked_snapshot(source_set, scheme)
1855 }
1856 }
1857
1858 #[test]
1859 fn check_request_rejects_source_groups_from_another_project_rules_file() {
1860 let analyzed = tempfile::tempdir().expect("analyzed project");
1861 let external = tempfile::tempdir().expect("external rules project");
1862 let external_rules = external.path().join(".code-moniker.toml");
1863 std::fs::write(
1864 &external_rules,
1865 r#"
1866default_rules = false
1867
1868[[workspace.source_group]]
1869roots = ["src"]
1870"#,
1871 )
1872 .expect("write external project config");
1873 let request = CheckRequest::new(
1874 analyzed.path(),
1875 RuleSetRequest::with_rules(&external_rules, "code+moniker://"),
1876 );
1877
1878 let error = request
1879 .run()
1880 .expect_err("structural config must belong to the analyzed project");
1881 assert!(
1882 error
1883 .to_string()
1884 .contains("may be declared only in the canonical"),
1885 "{error:#}"
1886 );
1887 }
1888
1889 #[test]
1890 fn descendant_catalog_reads_only_candidate_roots_once() {
1891 let root = Path::new("/project");
1892 let workspace = RecordingWorkspace::new(
1893 MemoryCheckWorkspace::new(root)
1894 .with_file("src/tools/mod.rs", "mod read; mod symbols;", Lang::Rs)
1895 .with_file("src/tools/read.rs", "fn same_helper() {}", Lang::Rs)
1896 .with_file("src/tools/symbols.rs", "fn same_helper() {}", Lang::Rs)
1897 .with_file("src/unrelated.rs", "fn same_helper() {}", Lang::Rs),
1898 );
1899 let catalog = workspace.source_catalog(root).expect("source catalog");
1900 let resolver = FileRequirementResolver::new(root.to_path_buf(), catalog, &[], &workspace);
1901 let graph = environment::extract_source_with(
1902 Lang::Rs,
1903 "mod read; mod symbols;",
1904 Path::new("src/tools/mod.rs"),
1905 &environment::ExtractContext::default(),
1906 );
1907 let owner = graph.defs().next().expect("module root");
1908 let domain = Domain::Children("fn".to_string());
1909
1910 let first = check::RequirementResolver::descendant_defs(&resolver, owner, &domain);
1911 assert_eq!(first.len(), 2);
1912 let second = check::RequirementResolver::descendant_defs(&resolver, owner, &domain);
1913 assert_eq!(second.len(), 2);
1914
1915 let mut reads = workspace.reads();
1916 reads.sort();
1917 assert_eq!(
1918 reads,
1919 vec![
1920 root.join("src/tools/read.rs"),
1921 root.join("src/tools/symbols.rs")
1922 ]
1923 );
1924 assert!(!reads.contains(&root.join("src/unrelated.rs")));
1925 assert!(!reads.contains(&root.join("src/tools/mod.rs")));
1926 }
1927
1928 #[test]
1929 fn false_lazy_rule_does_not_build_the_source_catalog() {
1930 let root = Path::new("/project");
1931 let workspace = RecordingWorkspace::new(
1932 MemoryCheckWorkspace::new(root)
1933 .with_file("src/lib.rs", "pub fn ready() {}", Lang::Rs)
1934 .with_file("src/tools/mod.rs", "mod read;", Lang::Rs)
1935 .with_file("src/tools/read.rs", "fn helper() {}", Lang::Rs),
1936 );
1937 let rules = RuleSetRequest::new(None, "code+moniker://")
1938 .with_default_rules(DefaultRulesSelection::Disabled)
1939 .with_inline_rules(vec![
1940 r#"
1941 [[rust.module.where]]
1942 id = "tools-descendants"
1943 expr = "uri ~ '**/dir:src/module:tools' => count(descendants(fn)) = 0"
1944 message = "tools descendants"
1945 "#
1946 .to_string(),
1947 ]);
1948 let request = CheckRequest::new(root, rules).with_files(vec![PathBuf::from("src/lib.rs")]);
1949
1950 let run = request
1951 .run_with_workspace(&workspace)
1952 .expect("filtered check");
1953
1954 assert_eq!(run.reports.len(), 1);
1955 assert!(run.reports[0].violations.is_empty());
1956 assert_eq!(workspace.catalog_calls.load(Ordering::Relaxed), 0);
1957 assert_eq!(workspace.reads(), vec![root.join("src/lib.rs")]);
1958 }
1959
1960 #[test]
1961 fn reached_lazy_rule_reports_source_catalog_errors() {
1962 let root = Path::new("/project");
1963 let workspace = RecordingWorkspace::new(MemoryCheckWorkspace::new(root).with_file(
1964 "src/tools/mod.rs",
1965 "pub fn ready() {}",
1966 Lang::Rs,
1967 ))
1968 .with_catalog_error();
1969 let rules = RuleSetRequest::new(None, "code+moniker://")
1970 .with_default_rules(DefaultRulesSelection::Disabled)
1971 .with_inline_rules(vec![
1972 r#"
1973 [[rust.module.where]]
1974 id = "tools-descendants"
1975 expr = "uri ~ '**/dir:src/module:tools' => count(descendants(fn)) = 0"
1976 message = "tools descendants"
1977 "#
1978 .to_string(),
1979 ]);
1980 let request =
1981 CheckRequest::new(root, rules).with_files(vec![PathBuf::from("src/tools/mod.rs")]);
1982
1983 let run = request
1984 .run_with_workspace(&workspace)
1985 .expect("filtered check");
1986
1987 assert!(run.any_error());
1988 assert_eq!(run.errors.len(), 1);
1989 assert_eq!(run.errors[0].path, root);
1990 assert!(run.errors[0].error.contains("catalog unavailable"));
1991 assert_eq!(workspace.catalog_calls.load(Ordering::Relaxed), 1);
1992 }
1993
1994 #[test]
1995 fn indexed_workspace_is_built_from_one_cached_snapshot_generation() {
1996 let temp = tempfile::tempdir().expect("tempdir");
1997 let source = temp.path().join("lib.rs");
1998 std::fs::write(&source, "pub fn stale() {}\n").expect("write initial source");
1999 let cache = LocalResourceCache::default();
2000 let mut registry = LocalWorkspaceRegistry::local_with_cache(
2001 LocalWorkspaceOptions::new(vec![temp.path().to_path_buf()], None),
2002 cache.clone(),
2003 );
2004
2005 assert!(matches!(
2006 registry
2007 .commands()
2008 .refresh(WorkspaceRequest::new("initial")),
2009 WorkspaceTransition::Ready { .. }
2010 ));
2011 let stale = registry.queries().snapshot_arc().expect("stale snapshot");
2012 let stale_material = cache
2013 .index_material(stale.index.generation)
2014 .expect("stale material");
2015
2016 std::fs::write(&source, "pub fn current() {}\n").expect("write refreshed source");
2017 assert!(matches!(
2018 registry
2019 .commands()
2020 .refresh(WorkspaceRequest::new("refresh")),
2021 WorkspaceTransition::Ready { .. }
2022 ));
2023 let current = registry.queries().snapshot_arc().expect("current snapshot");
2024
2025 assert!(
2026 Arc::strong_count(&stale_material) > 0,
2027 "the old material remains available to a caller holding it"
2028 );
2029 let error = IndexedCheckWorkspace::from_snapshot(temp.path(), &cache, Arc::clone(&stale))
2030 .err()
2031 .expect("an evicted snapshot generation must be rejected");
2032 assert!(
2033 error
2034 .to_string()
2035 .contains(&stale.index.generation.value().to_string())
2036 );
2037
2038 let workspace =
2039 IndexedCheckWorkspace::from_snapshot(temp.path(), &cache, Arc::clone(¤t))
2040 .expect("current generation");
2041 assert_eq!(
2042 workspace.read_to_string(&source).expect("indexed source"),
2043 "pub fn current() {}\n"
2044 );
2045 assert_eq!(
2046 workspace
2047 .linked_snapshot(workspace.material.source_set(), "code+moniker://")
2048 .expect("linked snapshot")
2049 .expect("snapshot")
2050 .index
2051 .generation,
2052 current.index.generation
2053 );
2054 }
2055}