Skip to main content

code_moniker_check/check/
command.rs

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/// One scanned file's rule outcome: the suppression-filtered violations and,
17/// when `report` is requested, the per-rule observability counts.
18#[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/// A per-file I/O or extraction failure, accumulated rather than aborting a
26/// project scan.
27#[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/// How a consumer wants embedded default rules to participate in a ruleset.
203#[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/// Ruleset construction contract shared by CLI, MCP, views, and harnesses.
230#[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/// Executable check request over either a file, a project root, or a filtered
310/// set of project-relative files.
311#[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/// Empty-scan reason. Renderers use it to preserve silent text hooks while
416/// still allowing structured JSON for intentionally empty scans.
417#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum CheckSkipReason {
419	ExcludedSingleFile,
420	UnsupportedSingleFile,
421	NoMatchingFiles,
422}
423
424/// Structured result of a check request. It contains no terminal formatting or
425/// process exit policy.
426#[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/// Serializable aggregate counters for renderers and machine consumers.
498#[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/// Per-rule failure count, sorted by severity and volume by [`CheckRun`].
512#[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/// Count of suppression-filtered violations in a check result.
520#[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/// Rules and violations from evaluating one in-memory source.
529#[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
634/// `moniker_anchor` overrides the path passed to the extractor - used by
635/// project mode to anchor each file's moniker on its path relative to the
636/// scan root. `None` means "same as `fs_path`" (single-file mode).
637fn 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
704/// Project-mode scan. Per-file I/O errors are accumulated in `Vec<FileError>`
705/// rather than aborting the scan. Rules are compiled once per language and
706/// shared across the parallel pool.
707pub 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}