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