1use std::collections::HashMap;
2use std::fs::read_to_string;
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6 CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_core::{AstGrep, Doc};
11use ast_grep_language::SupportLang;
12use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
21use crate::disable::filter_disabled;
22use crate::finding::Finding;
23use crate::metric::{self, Metric};
24use crate::resolution::{self, FileImports, ManifestResolver, ResolutionFilter, ResolutionKind};
25use crate::rule::{Language, Rule, RuleId, Severity};
26use crate::test_filter::{CfgTestRanges, TestPaths};
27
28mod orchestrate;
29
30pub use orchestrate::{count_severities, scan, scan_cached, scan_files, scan_files_cached};
31
32#[derive(Debug, Error)]
33pub enum ScanError {
34 #[error("failed to compile rule '{id}': {source}")]
35 RuleCompile {
36 id: RuleId,
37 #[source]
38 source: RuleConfigError,
39 },
40
41 #[error("invalid glob pattern: {0}")]
42 Glob(#[from] GlobError),
43
44 #[error("rule '{id}': metric rule is missing a threshold")]
45 InvalidMetricRule { id: RuleId },
46}
47
48#[derive(Serialize)]
51pub struct AstGrepRule<'a> {
52 pub id: &'a str,
53 pub language: SupportLang,
54 pub severity: &'a Severity,
55 pub message: &'a str,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub note: Option<&'a str>,
58 pub rule: &'a Value,
59 #[serde(skip_serializing_if = "Option::is_none")]
62 pub constraints: Option<&'a Value>,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 pub fix: Option<&'a str>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub files: Option<&'a [String]>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub ignores: Option<&'a [String]>,
71}
72
73struct CompiledRules<'a> {
76 collection: RuleCollection<SupportLang>,
77 by_id: HashMap<(&'a str, Language), &'a Rule>,
78 metrics: Vec<MetricRule<'a>>,
79 cross_file: Vec<CrossFileRule<'a>>,
80 resolution: Vec<ResolutionRule<'a>>,
81 test_paths: TestPaths,
84}
85
86struct CrossFileRule<'a> {
89 rule: &'a Rule,
90 kind: CrossFileKind,
91 decl_filter: DeclFilter,
92}
93
94struct ResolutionRule<'a> {
97 rule: &'a Rule,
98 kind: ResolutionKind,
99 filter: ResolutionFilter,
100}
101
102struct MetricRule<'a> {
106 rule: &'a Rule,
107 metric: Metric,
108 threshold: f64,
109 min_lines: Option<usize>,
110 files: Option<GlobSet>,
111 ignores: Option<GlobSet>,
112}
113
114impl MetricRule<'_> {
115 fn applies_to(&self, path: &Path, lang: &Language) -> bool {
116 self.rule.language == *lang
117 && self.files.as_ref().is_none_or(|g| g.is_match(path))
118 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
119 }
120}
121
122fn extension_to_lang(path: &Path) -> Option<SupportLang> {
123 SupportLang::from_path(path)
124 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
125}
126
127pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
128 match lang {
129 SupportLang::Rust => Language::Rust,
130 _ => Language::TypeScript,
131 }
132}
133
134pub(crate) fn compile_ast_grep_rule(
135 rule: &AstGrepRule,
136) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
137 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
138 .and_then(singleton_map_recursive::deserialize)
139 .map_err(RuleConfigError::from)?;
140 RuleConfig::try_from(inner, &GlobalRules::default())
141}
142
143fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
144 let ast_grep_rule = AstGrepRule {
145 id: rule.id.as_str(),
146 language: lang,
147 severity: &rule.severity,
148 message: &rule.message,
149 note: rule.note.as_deref(),
150 rule: &rule.rule,
151 constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
152 fix: None,
153 files: rule.files.as_deref(),
154 ignores: rule.ignores.as_deref(),
155 };
156 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
157 id: rule.id.clone(),
158 source,
159 })
160}
161
162fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
164 let Some((metric, threshold)) = rule.metric_spec() else {
165 return Err(ScanError::InvalidMetricRule {
166 id: rule.id.clone(),
167 });
168 };
169 Ok(MetricRule {
170 rule,
171 metric,
172 threshold,
173 min_lines: rule.min_lines,
174 files: rule.files.as_deref().map(build_glob_set).transpose()?,
175 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
176 })
177}
178
179fn build_cross_file_rule<'a>(
183 rule: &'a Rule,
184 kind: CrossFileKind,
185 test_paths: &TestPaths,
186) -> Result<CrossFileRule<'a>, ScanError> {
187 Ok(CrossFileRule {
188 rule,
189 kind,
190 decl_filter: DeclFilter {
191 files: rule.files.as_deref().map(build_glob_set).transpose()?,
192 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
193 skip_test_code: rule.skip_test_code,
194 test_paths: test_paths.clone(),
195 },
196 })
197}
198
199fn build_resolution_rule<'a>(
201 rule: &'a Rule,
202 kind: ResolutionKind,
203) -> Result<ResolutionRule<'a>, ScanError> {
204 Ok(ResolutionRule {
205 rule,
206 kind,
207 filter: ResolutionFilter {
208 files: rule.files.as_deref().map(build_glob_set).transpose()?,
209 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
210 },
211 })
212}
213
214fn compile_rules<'a>(
215 rules: &'a [Rule],
216 test_paths: TestPaths,
217) -> Result<CompiledRules<'a>, ScanError> {
218 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
221 rules.iter().partition(|r| r.is_cross_file());
222 let (resolution_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
223 rest.into_iter().partition(|r| r.is_resolution());
224 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
225 rest.into_iter().partition(|r| r.is_metric());
226
227 let configs = ast_rules
228 .iter()
229 .copied()
230 .flat_map(|rule| {
231 rule.language
232 .ast_grep_langs()
233 .iter()
234 .map(move |lang| compile_rule(rule, *lang))
235 })
236 .collect::<Result<Vec<_>, _>>()?;
237 let cross_file = cross_rules
238 .into_iter()
239 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
240 .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
241 .collect::<Result<Vec<_>, _>>()?;
242 let resolution = resolution_rules
243 .into_iter()
244 .filter_map(|rule| rule.resolution_kind().map(|kind| (rule, kind)))
245 .map(|(rule, kind)| build_resolution_rule(rule, kind))
246 .collect::<Result<Vec<_>, _>>()?;
247 Ok(CompiledRules {
248 collection: RuleCollection::try_new(configs)?,
249 by_id: ast_rules
250 .iter()
251 .copied()
252 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
253 .collect::<HashMap<_, _>>(),
254 metrics: metric_rules
255 .into_iter()
256 .map(build_metric_rule)
257 .collect::<Result<Vec<_>, _>>()?,
258 cross_file,
259 resolution,
260 test_paths,
261 })
262}
263
264pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
265 let mut builder = GlobSetBuilder::new();
266 for pattern in patterns {
267 builder.add(Glob::new(pattern)?);
268 }
269 builder.build()
270}
271
272fn metric_findings<D: Doc>(
278 metrics: &[&MetricRule],
279 path: &Path,
280 root: &AstGrep<D>,
281 source: &str,
282 lang: &Language,
283 test_paths: &TestPaths,
284 cfg_test: Option<&CfgTestRanges>,
285) -> Vec<Finding> {
286 metrics
287 .iter()
288 .filter(|m| !(m.rule.skip_test_code && test_paths.is_test(path)))
289 .filter(|m| {
290 m.min_lines
291 .is_none_or(|floor| source.lines().count() >= floor)
292 })
293 .filter_map(|m| {
294 let exclude = m.rule.skip_test_code.then_some(cfg_test).flatten();
297 let value = metric::compute(m.metric, lang, root, source, exclude);
298 if !metric::exceeds(value, m.threshold) {
299 return None;
300 }
301 let rendered = m.metric.format_value(value);
302 Some(Finding {
303 rule_id: m.rule.id.clone(),
304 severity: m.rule.severity.clone(),
305 category: m.rule.category.clone().unwrap_or_default(),
306 message: m.rule.message.replace("$value", &rendered),
307 note: m.rule.note.clone(),
308 fix: m.rule.fix.clone(),
309 file: path.to_path_buf(),
310 line: 1,
311 column: 1,
312 end_line: 1,
313 end_column: 1,
314 matched_text: m.metric.describe(value),
315 confidence: None,
316 escalated: false,
317 })
318 })
319 .collect()
320}
321
322#[derive(Default)]
326struct FileScan {
327 findings: Vec<Finding>,
328 symbols: FileSymbols,
329 imports: FileImports,
330}
331
332fn scan_file(
333 path: &Path,
334 lang: SupportLang,
335 rules: &CompiledRules,
336 collect_symbols: bool,
337 collect_imports: bool,
338) -> FileScan {
339 let rule_lang = support_lang_to_language(lang);
340 let applicable = rules.collection.get_rule_from_lang(path, lang);
341 let metrics: Vec<&MetricRule> = rules
342 .metrics
343 .iter()
344 .filter(|m| m.applies_to(path, &rule_lang))
345 .collect();
346 let want_symbols = collect_symbols && lang == SupportLang::Rust;
350 let want_imports = collect_imports;
351 if applicable.is_empty() && metrics.is_empty() && !want_symbols && !want_imports {
352 return FileScan::default();
353 }
354 let Ok(source) = read_to_string(path) else {
355 return FileScan::default();
356 };
357
358 let root = lang.ast_grep(&source);
359 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
361
362 let mut findings: Vec<Finding> = if applicable.is_empty() {
363 Vec::new()
364 } else {
365 let combined = CombinedScan::new(applicable);
366 let result = combined.scan(&root, false);
367
368 let cfg_test = cfg_test.as_ref();
369 let is_test_file = rules.test_paths.is_test(path);
370
371 result
372 .matches
373 .into_iter()
374 .filter_map(|(config, matches)| {
375 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
376 Some((*rule, matches))
377 })
378 .flat_map(|(rule, matches)| {
379 let skip_test_code = rule.skip_test_code;
380 matches
381 .into_iter()
382 .map(move |node_match| {
383 let start = node_match.start_pos();
384 let end = node_match.end_pos();
385 Finding {
386 rule_id: rule.id.clone(),
387 severity: rule.severity.clone(),
388 category: rule.category.clone().unwrap_or_default(),
389 message: rule.message.clone(),
390 note: rule.note.clone(),
391 fix: rule.fix.clone(),
392 file: path.to_path_buf(),
393 line: start.line() + 1,
394 column: start.byte_point().1 + 1,
395 end_line: end.line() + 1,
396 end_column: end.byte_point().1 + 1,
397 matched_text: node_match.text().to_string(),
398 confidence: None,
399 escalated: false,
400 }
401 })
402 .filter(move |f| {
403 if !skip_test_code {
404 return true;
405 }
406 let in_test_code =
407 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
408 !in_test_code
409 })
410 })
411 .collect()
412 };
413
414 findings.extend(metric_findings(
415 &metrics,
416 path,
417 &root,
418 &source,
419 &rule_lang,
420 &rules.test_paths,
421 cfg_test.as_ref(),
422 ));
423
424 let symbols = match (want_symbols, cfg_test.as_ref()) {
427 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
428 _ => FileSymbols::default(),
429 };
430
431 let imports = if want_imports {
435 resolution::extract_imports(&rule_lang, &root)
436 } else {
437 FileImports::default()
438 };
439
440 FileScan {
441 findings: filter_disabled(findings, &source),
442 symbols,
443 imports,
444 }
445}
446
447fn cross_file_findings(
451 rules: &[CrossFileRule<'_>],
452 contributions: &[(PathBuf, FileSymbols)],
453) -> Vec<Finding> {
454 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
455 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
456 for rule in rules {
457 let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
458 for finding in found {
459 by_file
460 .entry(finding.file.clone())
461 .or_default()
462 .push(finding);
463 }
464 }
465 by_file
466 .into_iter()
467 .flat_map(|(path, findings)| match read_to_string(&path) {
468 Ok(source) => filter_disabled(findings, &source),
471 Err(_) => findings,
472 })
473 .collect()
474}
475
476fn append_cross_file(
482 findings: &mut Vec<Finding>,
483 compiled: &CompiledRules,
484 run_cross_file: bool,
485 contributions: &[(PathBuf, FileSymbols)],
486) {
487 if run_cross_file && !compiled.cross_file.is_empty() {
488 findings.extend(cross_file_findings(&compiled.cross_file, contributions));
489 }
490}
491
492fn resolution_findings(
497 rules: &[ResolutionRule<'_>],
498 contributions: &[(PathBuf, FileImports)],
499) -> Vec<Finding> {
500 let mut manifests = ManifestResolver::new();
501 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
502 for (path, imports) in contributions {
503 if imports.is_empty() {
504 continue;
505 }
506 let Some(lang) = extension_to_lang(path).map(support_lang_to_language) else {
507 continue;
508 };
509 for rule in rules {
510 if rule.rule.language != lang || !rule.filter.allows(path) {
511 continue;
512 }
513 let found = resolution::evaluate_file(
514 rule.rule,
515 rule.kind,
516 &lang,
517 path,
518 imports,
519 &mut manifests,
520 );
521 by_file.entry(path.clone()).or_default().extend(found);
522 }
523 }
524 by_file
525 .into_iter()
526 .flat_map(|(path, findings)| match read_to_string(&path) {
527 Ok(source) => filter_disabled(findings, &source),
530 Err(_) => findings,
531 })
532 .collect()
533}
534
535fn append_resolution(
540 findings: &mut Vec<Finding>,
541 compiled: &CompiledRules,
542 contributions: &[(PathBuf, FileImports)],
543) {
544 if !compiled.resolution.is_empty() {
545 findings.extend(resolution_findings(&compiled.resolution, contributions));
546 }
547}
548
549pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
551 paths
552 .iter()
553 .flat_map(|path| WalkBuilder::new(path).build().flatten())
554 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
555 .map(|entry| entry.into_path())
556 .filter(|path| !ignores.is_match(path))
557 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
558 .collect()
559}
560
561pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
567 files
568 .iter()
569 .filter(|path| path.is_file())
570 .filter(|path| !ignores.is_match(path))
571 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
572 .collect()
573}
574
575#[cfg(test)]
576mod feature_tests;
577#[cfg(test)]
578mod scan_tests;
579#[cfg(test)]
580mod test_support;