1use std::collections::HashMap;
2use std::fs::read_to_string;
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6 GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use serde::Serialize;
14use serde_yaml::with::singleton_map_recursive;
15use serde_yaml::{to_value, Value};
16use strum::IntoEnumIterator;
17use thiserror::Error;
18
19use crate::config::{Config, RuleOptions};
20use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols};
21use crate::disable::filter_disabled;
22use crate::finding::Finding;
23use crate::resolution::{self, FileImports, ManifestResolver, ResolutionFilter, ResolutionKind};
24use crate::rule::{Language, Rule, RuleId, Severity};
25use crate::test_filter::{CfgTestRanges, TestPaths};
26
27use engine::{
28 AstEngine, BoxedEngine, CrossFileEngine, CrossFileRule, EngineScope, FileContext, MetricEngine,
29 MetricRule, RuleContext,
30};
31
32mod engine;
33mod orchestrate;
34mod project;
35
36pub use orchestrate::{
37 count_severities, scan, scan_cached, scan_cached_with_key, scan_files, scan_files_cached,
38 scan_files_cached_with_key, scan_files_unused_disables, scan_unused_disables,
39};
40
41#[derive(Debug, Error)]
42pub enum ScanError {
43 #[error("failed to compile rule '{id}': {source}")]
44 RuleCompile {
45 id: RuleId,
46 #[source]
47 source: RuleConfigError,
48 },
49
50 #[error("invalid glob pattern: {0}")]
51 Glob(#[from] GlobError),
52
53 #[error("rule '{id}': metric rule is missing a threshold")]
54 InvalidMetricRule { id: RuleId },
55}
56
57#[derive(Serialize)]
60pub struct AstGrepRule<'a> {
61 pub id: &'a str,
62 pub language: SupportLang,
63 pub severity: &'a Severity,
64 pub message: &'a str,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub note: Option<&'a str>,
67 pub rule: &'a Value,
68 #[serde(skip_serializing_if = "Option::is_none")]
71 pub constraints: Option<&'a Value>,
72 #[serde(skip_serializing_if = "Option::is_none")]
75 pub fix: Option<&'a str>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub files: Option<&'a [String]>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub ignores: Option<&'a [String]>,
80}
81
82struct CompiledRules<'a> {
86 collection: RuleCollection<SupportLang>,
87 by_id: HashMap<(&'a str, Language), &'a Rule>,
88 metrics: Vec<MetricRule<'a>>,
89 cross_file: Vec<CrossFileRule<'a>>,
90 resolution: Vec<ResolutionRule<'a>>,
91 test_paths: TestPaths,
94}
95
96impl CompiledRules<'_> {
97 fn engines(&self, apply_disable: bool) -> (Vec<BoxedEngine<'_>>, Vec<BoxedEngine<'_>>) {
102 let mut all: Vec<BoxedEngine<'_>> = vec![Box::new(AstEngine {
103 collection: &self.collection,
104 by_id: &self.by_id,
105 })];
106 if !self.metrics.is_empty() {
107 all.push(Box::new(MetricEngine {
108 rules: &self.metrics,
109 }));
110 }
111 if !self.cross_file.is_empty() {
112 all.push(Box::new(CrossFileEngine {
113 rules: &self.cross_file,
114 apply_disable,
115 }));
116 }
117 all.into_iter()
118 .partition(|e| matches!(e.scope(), EngineScope::PerFile))
119 }
120}
121
122struct ResolutionRule<'a> {
127 rule: &'a Rule,
128 kind: ResolutionKind,
129 filter: ResolutionFilter,
130}
131
132fn extension_to_lang(path: &Path) -> Option<SupportLang> {
133 SupportLang::from_path(path)
134 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
135}
136
137pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
138 match lang {
139 SupportLang::Rust => Language::Rust,
140 _ => Language::TypeScript,
141 }
142}
143
144pub(crate) fn compile_ast_grep_rule(
145 rule: &AstGrepRule,
146) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
147 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
148 .and_then(singleton_map_recursive::deserialize)
149 .map_err(RuleConfigError::from)?;
150 RuleConfig::try_from(inner, &GlobalRules::default())
151}
152
153fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
154 let ast_grep_rule = AstGrepRule {
155 id: rule.id.as_str(),
156 language: lang,
157 severity: &rule.severity,
158 message: &rule.message,
159 note: rule.note.as_deref(),
160 rule: &rule.rule,
161 constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
162 fix: None,
163 files: rule.files.as_deref(),
164 ignores: rule.ignores.as_deref(),
165 };
166 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
167 id: rule.id.clone(),
168 source,
169 })
170}
171
172fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
174 let Some((metric, threshold)) = rule.metric_spec() else {
175 return Err(ScanError::InvalidMetricRule {
176 id: rule.id.clone(),
177 });
178 };
179 Ok(MetricRule {
180 rule,
181 metric,
182 threshold,
183 min_lines: rule.min_lines,
184 files: rule.files.as_deref().map(build_glob_set).transpose()?,
185 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
186 })
187}
188
189fn build_cross_file_rule<'a>(
193 rule: &'a Rule,
194 kind: CrossFileKind,
195 test_paths: &TestPaths,
196 options: &RuleOptions,
197) -> Result<CrossFileRule<'a>, ScanError> {
198 let assert_functions = match kind {
199 CrossFileKind::AssertionFreeTest => {
200 let names = &options.no_assertion_free_test.assert_functions;
201 (!names.is_empty())
202 .then(|| build_glob_set(names))
203 .transpose()?
204 }
205 CrossFileKind::SingleImplTrait | CrossFileKind::DuplicateErrorMessage => None,
206 };
207 Ok(CrossFileRule {
208 rule,
209 kind,
210 decl_filter: DeclFilter {
211 files: rule.files.as_deref().map(build_glob_set).transpose()?,
212 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
213 skip_test_code: rule.skip_test_code,
214 test_paths: test_paths.clone(),
215 assert_functions,
216 },
217 })
218}
219
220fn build_resolution_rule<'a>(
222 rule: &'a Rule,
223 kind: ResolutionKind,
224) -> Result<ResolutionRule<'a>, ScanError> {
225 Ok(ResolutionRule {
226 rule,
227 kind,
228 filter: ResolutionFilter {
229 files: rule.files.as_deref().map(build_glob_set).transpose()?,
230 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
231 },
232 })
233}
234
235fn compile_rules<'a>(rules: &'a [Rule], config: &Config) -> Result<CompiledRules<'a>, ScanError> {
238 let test_paths = TestPaths::new(&config.scan.test_paths)?;
239 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
242 rules.iter().partition(|r| r.is_cross_file());
243 let (resolution_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
244 rest.into_iter().partition(|r| r.is_resolution());
245 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
246 rest.into_iter().partition(|r| r.is_metric());
247
248 let configs = ast_rules
249 .iter()
250 .copied()
251 .flat_map(|rule| {
252 rule.language
253 .ast_grep_langs()
254 .iter()
255 .map(move |lang| compile_rule(rule, *lang))
256 })
257 .collect::<Result<Vec<_>, _>>()?;
258 let cross_file = cross_rules
259 .into_iter()
260 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
261 .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths, &config.rules.options))
262 .collect::<Result<Vec<_>, _>>()?;
263 let resolution = resolution_rules
264 .into_iter()
265 .filter_map(|rule| rule.resolution_kind().map(|kind| (rule, kind)))
266 .map(|(rule, kind)| build_resolution_rule(rule, kind))
267 .collect::<Result<Vec<_>, _>>()?;
268 Ok(CompiledRules {
269 collection: RuleCollection::try_new(configs)?,
270 by_id: ast_rules
271 .iter()
272 .copied()
273 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
274 .collect::<HashMap<_, _>>(),
275 metrics: metric_rules
276 .into_iter()
277 .map(build_metric_rule)
278 .collect::<Result<Vec<_>, _>>()?,
279 cross_file,
280 resolution,
281 test_paths,
282 })
283}
284
285pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
286 let mut builder = GlobSetBuilder::new();
287 for pattern in patterns {
288 builder.add(Glob::new(pattern)?);
289 }
290 builder.build()
291}
292
293#[derive(Default)]
297struct FileScan {
298 findings: Vec<Finding>,
299 symbols: FileSymbols,
300 imports: FileImports,
301}
302
303fn scan_file(
304 path: &Path,
305 lang: SupportLang,
306 per_file: &[BoxedEngine],
307 test_paths: &TestPaths,
308 collect_symbols: bool,
309 collect_imports: bool,
310 apply_disable: bool,
311) -> FileScan {
312 let applies = per_file.iter().any(|e| e.applies_to_file(path, lang));
313 let want_symbols = collect_symbols && lang == SupportLang::Rust;
317 let want_imports = collect_imports;
318 if !applies && !want_symbols && !want_imports {
319 return FileScan::default();
320 }
321 let Ok(source) = read_to_string(path) else {
322 return FileScan::default();
323 };
324
325 let root = lang.ast_grep(&source);
326 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
328
329 let ctx = RuleContext::File(FileContext {
330 path,
331 lang,
332 rule_lang: support_lang_to_language(lang),
333 source: &source,
334 root: &root,
335 cfg_test: cfg_test.as_ref(),
336 is_test_file: test_paths.is_test(path),
337 test_paths,
338 });
339
340 let mut findings: Vec<Finding> = Vec::new();
341 for engine in per_file {
342 findings.extend(engine.evaluate(&ctx));
343 }
344
345 let symbols = match (want_symbols, cfg_test.as_ref()) {
348 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
349 _ => FileSymbols::default(),
350 };
351
352 let imports = if want_imports {
356 resolution::extract_imports(&support_lang_to_language(lang), &root)
357 } else {
358 FileImports::default()
359 };
360
361 FileScan {
362 findings: if apply_disable {
363 filter_disabled(findings, &source)
364 } else {
365 findings
366 },
367 symbols,
368 imports,
369 }
370}
371
372fn resolution_findings(
377 rules: &[ResolutionRule<'_>],
378 contributions: &[(PathBuf, FileImports)],
379 apply_disable: bool,
380) -> Vec<Finding> {
381 let mut manifests = ManifestResolver::new();
382 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
383 for (path, imports) in contributions {
384 if imports.is_empty() {
385 continue;
386 }
387 let Some(lang) = extension_to_lang(path).map(support_lang_to_language) else {
388 continue;
389 };
390 for rule in rules {
391 if rule.rule.language != lang || !rule.filter.allows(path) {
392 continue;
393 }
394 let found = resolution::evaluate_file(
395 rule.rule,
396 rule.kind,
397 &lang,
398 path,
399 imports,
400 &mut manifests,
401 );
402 by_file.entry(path.clone()).or_default().extend(found);
403 }
404 }
405 by_file
406 .into_iter()
407 .flat_map(|(path, findings)| {
408 if !apply_disable {
409 return findings;
410 }
411 match read_to_string(&path) {
412 Ok(source) => filter_disabled(findings, &source),
415 Err(_) => findings,
416 }
417 })
418 .collect()
419}
420
421fn append_resolution(
426 findings: &mut Vec<Finding>,
427 compiled: &CompiledRules,
428 contributions: &[(PathBuf, FileImports)],
429 apply_disable: bool,
430) {
431 if !compiled.resolution.is_empty() {
432 findings.extend(resolution_findings(
433 &compiled.resolution,
434 contributions,
435 apply_disable,
436 ));
437 }
438}
439
440pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
442 paths
443 .iter()
444 .flat_map(|path| WalkBuilder::new(path).build().flatten())
445 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
446 .map(|entry| entry.into_path())
447 .filter(|path| !ignores.is_match(path))
448 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
449 .collect()
450}
451
452pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
458 files
459 .iter()
460 .filter(|path| path.is_file())
461 .filter(|path| !ignores.is_match(path))
462 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
463 .collect()
464}
465
466#[cfg(test)]
467mod feature_tests;
468#[cfg(test)]
469mod scan_tests;
470#[cfg(test)]
471mod test_support;