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::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
20use crate::disable::filter_disabled;
21use crate::finding::Finding;
22use crate::resolution::{self, FileImports, ManifestResolver, ResolutionFilter, ResolutionKind};
23use crate::rule::{Language, Rule, RuleId, Severity};
24use crate::test_filter::{CfgTestRanges, TestPaths};
25
26use engine::{
27 AstEngine, BoxedEngine, CrossFileEngine, CrossFileRule, EngineScope, FileContext, MetricEngine,
28 MetricRule, ProjectContext, RuleContext,
29};
30
31mod engine;
32mod orchestrate;
33
34pub use orchestrate::{
35 count_severities, scan, scan_cached, scan_files, scan_files_cached, scan_files_unused_disables,
36 scan_unused_disables,
37};
38
39#[derive(Debug, Error)]
40pub enum ScanError {
41 #[error("failed to compile rule '{id}': {source}")]
42 RuleCompile {
43 id: RuleId,
44 #[source]
45 source: RuleConfigError,
46 },
47
48 #[error("invalid glob pattern: {0}")]
49 Glob(#[from] GlobError),
50
51 #[error("rule '{id}': metric rule is missing a threshold")]
52 InvalidMetricRule { id: RuleId },
53}
54
55#[derive(Serialize)]
58pub struct AstGrepRule<'a> {
59 pub id: &'a str,
60 pub language: SupportLang,
61 pub severity: &'a Severity,
62 pub message: &'a str,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub note: Option<&'a str>,
65 pub rule: &'a Value,
66 #[serde(skip_serializing_if = "Option::is_none")]
69 pub constraints: Option<&'a Value>,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub fix: Option<&'a str>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub files: Option<&'a [String]>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub ignores: Option<&'a [String]>,
78}
79
80struct CompiledRules<'a> {
84 collection: RuleCollection<SupportLang>,
85 by_id: HashMap<(&'a str, Language), &'a Rule>,
86 metrics: Vec<MetricRule<'a>>,
87 cross_file: Vec<CrossFileRule<'a>>,
88 resolution: Vec<ResolutionRule<'a>>,
89 test_paths: TestPaths,
92}
93
94impl CompiledRules<'_> {
95 fn engines(&self, apply_disable: bool) -> (Vec<BoxedEngine<'_>>, Vec<BoxedEngine<'_>>) {
100 let mut all: Vec<BoxedEngine<'_>> = vec![Box::new(AstEngine {
101 collection: &self.collection,
102 by_id: &self.by_id,
103 })];
104 if !self.metrics.is_empty() {
105 all.push(Box::new(MetricEngine {
106 rules: &self.metrics,
107 }));
108 }
109 if !self.cross_file.is_empty() {
110 all.push(Box::new(CrossFileEngine {
111 rules: &self.cross_file,
112 apply_disable,
113 }));
114 }
115 all.into_iter()
116 .partition(|e| matches!(e.scope(), EngineScope::PerFile))
117 }
118}
119
120struct ResolutionRule<'a> {
125 rule: &'a Rule,
126 kind: ResolutionKind,
127 filter: ResolutionFilter,
128}
129
130fn extension_to_lang(path: &Path) -> Option<SupportLang> {
131 SupportLang::from_path(path)
132 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
133}
134
135pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
136 match lang {
137 SupportLang::Rust => Language::Rust,
138 _ => Language::TypeScript,
139 }
140}
141
142pub(crate) fn compile_ast_grep_rule(
143 rule: &AstGrepRule,
144) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
145 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
146 .and_then(singleton_map_recursive::deserialize)
147 .map_err(RuleConfigError::from)?;
148 RuleConfig::try_from(inner, &GlobalRules::default())
149}
150
151fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
152 let ast_grep_rule = AstGrepRule {
153 id: rule.id.as_str(),
154 language: lang,
155 severity: &rule.severity,
156 message: &rule.message,
157 note: rule.note.as_deref(),
158 rule: &rule.rule,
159 constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
160 fix: None,
161 files: rule.files.as_deref(),
162 ignores: rule.ignores.as_deref(),
163 };
164 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
165 id: rule.id.clone(),
166 source,
167 })
168}
169
170fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
172 let Some((metric, threshold)) = rule.metric_spec() else {
173 return Err(ScanError::InvalidMetricRule {
174 id: rule.id.clone(),
175 });
176 };
177 Ok(MetricRule {
178 rule,
179 metric,
180 threshold,
181 min_lines: rule.min_lines,
182 files: rule.files.as_deref().map(build_glob_set).transpose()?,
183 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
184 })
185}
186
187fn build_cross_file_rule<'a>(
191 rule: &'a Rule,
192 kind: CrossFileKind,
193 test_paths: &TestPaths,
194) -> Result<CrossFileRule<'a>, ScanError> {
195 Ok(CrossFileRule {
196 rule,
197 kind,
198 decl_filter: DeclFilter {
199 files: rule.files.as_deref().map(build_glob_set).transpose()?,
200 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
201 skip_test_code: rule.skip_test_code,
202 test_paths: test_paths.clone(),
203 },
204 })
205}
206
207fn build_resolution_rule<'a>(
209 rule: &'a Rule,
210 kind: ResolutionKind,
211) -> Result<ResolutionRule<'a>, ScanError> {
212 Ok(ResolutionRule {
213 rule,
214 kind,
215 filter: ResolutionFilter {
216 files: rule.files.as_deref().map(build_glob_set).transpose()?,
217 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
218 },
219 })
220}
221
222fn compile_rules<'a>(
223 rules: &'a [Rule],
224 test_paths: TestPaths,
225) -> Result<CompiledRules<'a>, ScanError> {
226 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
229 rules.iter().partition(|r| r.is_cross_file());
230 let (resolution_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
231 rest.into_iter().partition(|r| r.is_resolution());
232 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
233 rest.into_iter().partition(|r| r.is_metric());
234
235 let configs = ast_rules
236 .iter()
237 .copied()
238 .flat_map(|rule| {
239 rule.language
240 .ast_grep_langs()
241 .iter()
242 .map(move |lang| compile_rule(rule, *lang))
243 })
244 .collect::<Result<Vec<_>, _>>()?;
245 let cross_file = cross_rules
246 .into_iter()
247 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
248 .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
249 .collect::<Result<Vec<_>, _>>()?;
250 let resolution = resolution_rules
251 .into_iter()
252 .filter_map(|rule| rule.resolution_kind().map(|kind| (rule, kind)))
253 .map(|(rule, kind)| build_resolution_rule(rule, kind))
254 .collect::<Result<Vec<_>, _>>()?;
255 Ok(CompiledRules {
256 collection: RuleCollection::try_new(configs)?,
257 by_id: ast_rules
258 .iter()
259 .copied()
260 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
261 .collect::<HashMap<_, _>>(),
262 metrics: metric_rules
263 .into_iter()
264 .map(build_metric_rule)
265 .collect::<Result<Vec<_>, _>>()?,
266 cross_file,
267 resolution,
268 test_paths,
269 })
270}
271
272pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
273 let mut builder = GlobSetBuilder::new();
274 for pattern in patterns {
275 builder.add(Glob::new(pattern)?);
276 }
277 builder.build()
278}
279
280#[derive(Default)]
284struct FileScan {
285 findings: Vec<Finding>,
286 symbols: FileSymbols,
287 imports: FileImports,
288}
289
290fn scan_file(
291 path: &Path,
292 lang: SupportLang,
293 per_file: &[BoxedEngine],
294 test_paths: &TestPaths,
295 collect_symbols: bool,
296 collect_imports: bool,
297 apply_disable: bool,
298) -> FileScan {
299 let applies = per_file.iter().any(|e| e.applies_to_file(path, lang));
300 let want_symbols = collect_symbols && lang == SupportLang::Rust;
304 let want_imports = collect_imports;
305 if !applies && !want_symbols && !want_imports {
306 return FileScan::default();
307 }
308 let Ok(source) = read_to_string(path) else {
309 return FileScan::default();
310 };
311
312 let root = lang.ast_grep(&source);
313 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
315
316 let ctx = RuleContext::File(FileContext {
317 path,
318 lang,
319 rule_lang: support_lang_to_language(lang),
320 source: &source,
321 root: &root,
322 cfg_test: cfg_test.as_ref(),
323 is_test_file: test_paths.is_test(path),
324 test_paths,
325 });
326
327 let mut findings: Vec<Finding> = Vec::new();
328 for engine in per_file {
329 findings.extend(engine.evaluate(&ctx));
330 }
331
332 let symbols = match (want_symbols, cfg_test.as_ref()) {
335 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
336 _ => FileSymbols::default(),
337 };
338
339 let imports = if want_imports {
343 resolution::extract_imports(&support_lang_to_language(lang), &root)
344 } else {
345 FileImports::default()
346 };
347
348 FileScan {
349 findings: if apply_disable {
350 filter_disabled(findings, &source)
351 } else {
352 findings
353 },
354 symbols,
355 imports,
356 }
357}
358
359fn append_cross_file(
366 findings: &mut Vec<Finding>,
367 project: &[BoxedEngine],
368 run_cross_file: bool,
369 contributions: &[(PathBuf, FileSymbols)],
370) {
371 if !run_cross_file || project.is_empty() {
372 return;
373 }
374 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
375 let ctx = RuleContext::Project(ProjectContext { index: &index });
376 for engine in project {
377 findings.extend(engine.evaluate(&ctx));
378 }
379}
380
381fn resolution_findings(
386 rules: &[ResolutionRule<'_>],
387 contributions: &[(PathBuf, FileImports)],
388 apply_disable: bool,
389) -> Vec<Finding> {
390 let mut manifests = ManifestResolver::new();
391 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
392 for (path, imports) in contributions {
393 if imports.is_empty() {
394 continue;
395 }
396 let Some(lang) = extension_to_lang(path).map(support_lang_to_language) else {
397 continue;
398 };
399 for rule in rules {
400 if rule.rule.language != lang || !rule.filter.allows(path) {
401 continue;
402 }
403 let found = resolution::evaluate_file(
404 rule.rule,
405 rule.kind,
406 &lang,
407 path,
408 imports,
409 &mut manifests,
410 );
411 by_file.entry(path.clone()).or_default().extend(found);
412 }
413 }
414 by_file
415 .into_iter()
416 .flat_map(|(path, findings)| {
417 if !apply_disable {
418 return findings;
419 }
420 match read_to_string(&path) {
421 Ok(source) => filter_disabled(findings, &source),
424 Err(_) => findings,
425 }
426 })
427 .collect()
428}
429
430fn append_resolution(
435 findings: &mut Vec<Finding>,
436 compiled: &CompiledRules,
437 contributions: &[(PathBuf, FileImports)],
438 apply_disable: bool,
439) {
440 if !compiled.resolution.is_empty() {
441 findings.extend(resolution_findings(
442 &compiled.resolution,
443 contributions,
444 apply_disable,
445 ));
446 }
447}
448
449pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
451 paths
452 .iter()
453 .flat_map(|path| WalkBuilder::new(path).build().flatten())
454 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
455 .map(|entry| entry.into_path())
456 .filter(|path| !ignores.is_match(path))
457 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
458 .collect()
459}
460
461pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
467 files
468 .iter()
469 .filter(|path| path.is_file())
470 .filter(|path| !ignores.is_match(path))
471 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
472 .collect()
473}
474
475#[cfg(test)]
476mod feature_tests;
477#[cfg(test)]
478mod scan_tests;
479#[cfg(test)]
480mod test_support;