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::rule::{Language, Rule, RuleId, Severity};
25use crate::test_filter::{CfgTestRanges, TestPaths};
26
27mod orchestrate;
28
29pub use orchestrate::{count_severities, scan, scan_cached, scan_files, scan_files_cached};
30
31#[derive(Debug, Error)]
32pub enum ScanError {
33 #[error("failed to compile rule '{id}': {source}")]
34 RuleCompile {
35 id: RuleId,
36 #[source]
37 source: RuleConfigError,
38 },
39
40 #[error("invalid glob pattern: {0}")]
41 Glob(#[from] GlobError),
42
43 #[error("rule '{id}': metric rule is missing a threshold")]
44 InvalidMetricRule { id: RuleId },
45}
46
47#[derive(Serialize)]
50pub struct AstGrepRule<'a> {
51 pub id: &'a str,
52 pub language: SupportLang,
53 pub severity: &'a Severity,
54 pub message: &'a str,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub note: Option<&'a str>,
57 pub rule: &'a Value,
58 #[serde(skip_serializing_if = "Option::is_none")]
61 pub constraints: Option<&'a Value>,
62 #[serde(skip_serializing_if = "Option::is_none")]
65 pub fix: Option<&'a str>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub files: Option<&'a [String]>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub ignores: Option<&'a [String]>,
70}
71
72struct CompiledRules<'a> {
75 collection: RuleCollection<SupportLang>,
76 by_id: HashMap<(&'a str, Language), &'a Rule>,
77 metrics: Vec<MetricRule<'a>>,
78 cross_file: Vec<CrossFileRule<'a>>,
79 test_paths: TestPaths,
82}
83
84struct CrossFileRule<'a> {
87 rule: &'a Rule,
88 kind: CrossFileKind,
89 decl_filter: DeclFilter,
90}
91
92struct MetricRule<'a> {
96 rule: &'a Rule,
97 metric: Metric,
98 threshold: f64,
99 min_lines: Option<usize>,
100 files: Option<GlobSet>,
101 ignores: Option<GlobSet>,
102}
103
104impl MetricRule<'_> {
105 fn applies_to(&self, path: &Path, lang: &Language) -> bool {
106 self.rule.language == *lang
107 && self.files.as_ref().is_none_or(|g| g.is_match(path))
108 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
109 }
110}
111
112fn extension_to_lang(path: &Path) -> Option<SupportLang> {
113 SupportLang::from_path(path)
114 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
115}
116
117pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
118 match lang {
119 SupportLang::Rust => Language::Rust,
120 _ => Language::TypeScript,
121 }
122}
123
124pub(crate) fn compile_ast_grep_rule(
125 rule: &AstGrepRule,
126) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
127 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
128 .and_then(singleton_map_recursive::deserialize)
129 .map_err(RuleConfigError::from)?;
130 RuleConfig::try_from(inner, &GlobalRules::default())
131}
132
133fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
134 let ast_grep_rule = AstGrepRule {
135 id: rule.id.as_str(),
136 language: lang,
137 severity: &rule.severity,
138 message: &rule.message,
139 note: rule.note.as_deref(),
140 rule: &rule.rule,
141 constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
142 fix: None,
143 files: rule.files.as_deref(),
144 ignores: rule.ignores.as_deref(),
145 };
146 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
147 id: rule.id.clone(),
148 source,
149 })
150}
151
152fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
154 let Some((metric, threshold)) = rule.metric_spec() else {
155 return Err(ScanError::InvalidMetricRule {
156 id: rule.id.clone(),
157 });
158 };
159 Ok(MetricRule {
160 rule,
161 metric,
162 threshold,
163 min_lines: rule.min_lines,
164 files: rule.files.as_deref().map(build_glob_set).transpose()?,
165 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
166 })
167}
168
169fn build_cross_file_rule<'a>(
173 rule: &'a Rule,
174 kind: CrossFileKind,
175 test_paths: &TestPaths,
176) -> Result<CrossFileRule<'a>, ScanError> {
177 Ok(CrossFileRule {
178 rule,
179 kind,
180 decl_filter: DeclFilter {
181 files: rule.files.as_deref().map(build_glob_set).transpose()?,
182 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
183 skip_test_code: rule.skip_test_code,
184 test_paths: test_paths.clone(),
185 },
186 })
187}
188
189fn compile_rules<'a>(
190 rules: &'a [Rule],
191 test_paths: TestPaths,
192) -> Result<CompiledRules<'a>, ScanError> {
193 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
196 rules.iter().partition(|r| r.is_cross_file());
197 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
198 rest.into_iter().partition(|r| r.is_metric());
199
200 let configs = ast_rules
201 .iter()
202 .copied()
203 .flat_map(|rule| {
204 rule.language
205 .ast_grep_langs()
206 .iter()
207 .map(move |lang| compile_rule(rule, *lang))
208 })
209 .collect::<Result<Vec<_>, _>>()?;
210 let cross_file = cross_rules
211 .into_iter()
212 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
213 .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
214 .collect::<Result<Vec<_>, _>>()?;
215 Ok(CompiledRules {
216 collection: RuleCollection::try_new(configs)?,
217 by_id: ast_rules
218 .iter()
219 .copied()
220 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
221 .collect::<HashMap<_, _>>(),
222 metrics: metric_rules
223 .into_iter()
224 .map(build_metric_rule)
225 .collect::<Result<Vec<_>, _>>()?,
226 cross_file,
227 test_paths,
228 })
229}
230
231pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
232 let mut builder = GlobSetBuilder::new();
233 for pattern in patterns {
234 builder.add(Glob::new(pattern)?);
235 }
236 builder.build()
237}
238
239fn metric_findings<D: Doc>(
245 metrics: &[&MetricRule],
246 path: &Path,
247 root: &AstGrep<D>,
248 source: &str,
249 lang: &Language,
250 test_paths: &TestPaths,
251 cfg_test: Option<&CfgTestRanges>,
252) -> Vec<Finding> {
253 metrics
254 .iter()
255 .filter(|m| !(m.rule.skip_test_code && test_paths.is_test(path)))
256 .filter(|m| {
257 m.min_lines
258 .is_none_or(|floor| source.lines().count() >= floor)
259 })
260 .filter_map(|m| {
261 let exclude = m.rule.skip_test_code.then_some(cfg_test).flatten();
264 let value = metric::compute(m.metric, lang, root, source, exclude);
265 if !metric::exceeds(value, m.threshold) {
266 return None;
267 }
268 let rendered = m.metric.format_value(value);
269 Some(Finding {
270 rule_id: m.rule.id.clone(),
271 severity: m.rule.severity.clone(),
272 category: m.rule.category.clone().unwrap_or_default(),
273 message: m.rule.message.replace("$value", &rendered),
274 note: m.rule.note.clone(),
275 fix: m.rule.fix.clone(),
276 file: path.to_path_buf(),
277 line: 1,
278 column: 1,
279 end_line: 1,
280 end_column: 1,
281 matched_text: m.metric.describe(value),
282 confidence: None,
283 escalated: false,
284 })
285 })
286 .collect()
287}
288
289#[derive(Default)]
293struct FileScan {
294 findings: Vec<Finding>,
295 symbols: FileSymbols,
296}
297
298fn scan_file(
299 path: &Path,
300 lang: SupportLang,
301 rules: &CompiledRules,
302 collect_symbols: bool,
303) -> FileScan {
304 let rule_lang = support_lang_to_language(lang);
305 let applicable = rules.collection.get_rule_from_lang(path, lang);
306 let metrics: Vec<&MetricRule> = rules
307 .metrics
308 .iter()
309 .filter(|m| m.applies_to(path, &rule_lang))
310 .collect();
311 let want_symbols = collect_symbols && lang == SupportLang::Rust;
314 if applicable.is_empty() && metrics.is_empty() && !want_symbols {
315 return FileScan::default();
316 }
317 let Ok(source) = read_to_string(path) else {
318 return FileScan::default();
319 };
320
321 let root = lang.ast_grep(&source);
322 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
324
325 let mut findings: Vec<Finding> = if applicable.is_empty() {
326 Vec::new()
327 } else {
328 let combined = CombinedScan::new(applicable);
329 let result = combined.scan(&root, false);
330
331 let cfg_test = cfg_test.as_ref();
332 let is_test_file = rules.test_paths.is_test(path);
333
334 result
335 .matches
336 .into_iter()
337 .filter_map(|(config, matches)| {
338 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
339 Some((*rule, matches))
340 })
341 .flat_map(|(rule, matches)| {
342 let skip_test_code = rule.skip_test_code;
343 matches
344 .into_iter()
345 .map(move |node_match| {
346 let start = node_match.start_pos();
347 let end = node_match.end_pos();
348 Finding {
349 rule_id: rule.id.clone(),
350 severity: rule.severity.clone(),
351 category: rule.category.clone().unwrap_or_default(),
352 message: rule.message.clone(),
353 note: rule.note.clone(),
354 fix: rule.fix.clone(),
355 file: path.to_path_buf(),
356 line: start.line() + 1,
357 column: start.byte_point().1 + 1,
358 end_line: end.line() + 1,
359 end_column: end.byte_point().1 + 1,
360 matched_text: node_match.text().to_string(),
361 confidence: None,
362 escalated: false,
363 }
364 })
365 .filter(move |f| {
366 if !skip_test_code {
367 return true;
368 }
369 let in_test_code =
370 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
371 !in_test_code
372 })
373 })
374 .collect()
375 };
376
377 findings.extend(metric_findings(
378 &metrics,
379 path,
380 &root,
381 &source,
382 &rule_lang,
383 &rules.test_paths,
384 cfg_test.as_ref(),
385 ));
386
387 let symbols = match (want_symbols, cfg_test.as_ref()) {
390 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
391 _ => FileSymbols::default(),
392 };
393
394 FileScan {
395 findings: filter_disabled(findings, &source),
396 symbols,
397 }
398}
399
400fn cross_file_findings(
404 rules: &[CrossFileRule<'_>],
405 contributions: &[(PathBuf, FileSymbols)],
406) -> Vec<Finding> {
407 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
408 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
409 for rule in rules {
410 let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
411 for finding in found {
412 by_file
413 .entry(finding.file.clone())
414 .or_default()
415 .push(finding);
416 }
417 }
418 by_file
419 .into_iter()
420 .flat_map(|(path, findings)| match read_to_string(&path) {
421 Ok(source) => filter_disabled(findings, &source),
424 Err(_) => findings,
425 })
426 .collect()
427}
428
429fn append_cross_file(
435 findings: &mut Vec<Finding>,
436 compiled: &CompiledRules,
437 run_cross_file: bool,
438 contributions: &[(PathBuf, FileSymbols)],
439) {
440 if run_cross_file && !compiled.cross_file.is_empty() {
441 findings.extend(cross_file_findings(&compiled.cross_file, contributions));
442 }
443}
444
445pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
447 paths
448 .iter()
449 .flat_map(|path| WalkBuilder::new(path).build().flatten())
450 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
451 .map(|entry| entry.into_path())
452 .filter(|path| !ignores.is_match(path))
453 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
454 .collect()
455}
456
457pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
463 files
464 .iter()
465 .filter(|path| path.is_file())
466 .filter(|path| !ignores.is_match(path))
467 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
468 .collect()
469}
470
471#[cfg(test)]
472mod feature_tests;
473#[cfg(test)]
474mod scan_tests;
475#[cfg(test)]
476mod test_support;