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")]
59 pub files: Option<&'a [String]>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub ignores: Option<&'a [String]>,
62}
63
64struct CompiledRules<'a> {
67 collection: RuleCollection<SupportLang>,
68 by_id: HashMap<(&'a str, Language), &'a Rule>,
69 metrics: Vec<MetricRule<'a>>,
70 cross_file: Vec<CrossFileRule<'a>>,
71 test_paths: TestPaths,
74}
75
76struct CrossFileRule<'a> {
79 rule: &'a Rule,
80 kind: CrossFileKind,
81 decl_filter: DeclFilter,
82}
83
84struct MetricRule<'a> {
88 rule: &'a Rule,
89 metric: Metric,
90 threshold: f64,
91 min_lines: Option<usize>,
92 files: Option<GlobSet>,
93 ignores: Option<GlobSet>,
94}
95
96impl MetricRule<'_> {
97 fn applies_to(&self, path: &Path, lang: &Language) -> bool {
98 self.rule.language == *lang
99 && self.files.as_ref().is_none_or(|g| g.is_match(path))
100 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
101 }
102}
103
104fn extension_to_lang(path: &Path) -> Option<SupportLang> {
105 SupportLang::from_path(path)
106 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
107}
108
109fn support_lang_to_language(lang: SupportLang) -> Language {
110 match lang {
111 SupportLang::Rust => Language::Rust,
112 _ => Language::TypeScript,
113 }
114}
115
116pub(crate) fn compile_ast_grep_rule(
117 rule: &AstGrepRule,
118) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
119 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
120 .and_then(singleton_map_recursive::deserialize)
121 .map_err(RuleConfigError::from)?;
122 RuleConfig::try_from(inner, &GlobalRules::default())
123}
124
125fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
126 let ast_grep_rule = AstGrepRule {
127 id: rule.id.as_str(),
128 language: lang,
129 severity: &rule.severity,
130 message: &rule.message,
131 note: rule.note.as_deref(),
132 rule: &rule.rule,
133 files: rule.files.as_deref(),
134 ignores: rule.ignores.as_deref(),
135 };
136 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
137 id: rule.id.clone(),
138 source,
139 })
140}
141
142fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
144 let Some((metric, threshold)) = rule.metric_spec() else {
145 return Err(ScanError::InvalidMetricRule {
146 id: rule.id.clone(),
147 });
148 };
149 Ok(MetricRule {
150 rule,
151 metric,
152 threshold,
153 min_lines: rule.min_lines,
154 files: rule.files.as_deref().map(build_glob_set).transpose()?,
155 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
156 })
157}
158
159fn build_cross_file_rule<'a>(
163 rule: &'a Rule,
164 kind: CrossFileKind,
165 test_paths: &TestPaths,
166) -> Result<CrossFileRule<'a>, ScanError> {
167 Ok(CrossFileRule {
168 rule,
169 kind,
170 decl_filter: DeclFilter {
171 files: rule.files.as_deref().map(build_glob_set).transpose()?,
172 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
173 skip_test_code: rule.skip_test_code,
174 test_paths: test_paths.clone(),
175 },
176 })
177}
178
179fn compile_rules<'a>(
180 rules: &'a [Rule],
181 test_paths: TestPaths,
182) -> Result<CompiledRules<'a>, ScanError> {
183 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
186 rules.iter().partition(|r| r.is_cross_file());
187 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
188 rest.into_iter().partition(|r| r.is_metric());
189
190 let configs = ast_rules
191 .iter()
192 .copied()
193 .flat_map(|rule| {
194 rule.language
195 .ast_grep_langs()
196 .iter()
197 .map(move |lang| compile_rule(rule, *lang))
198 })
199 .collect::<Result<Vec<_>, _>>()?;
200 let cross_file = cross_rules
201 .into_iter()
202 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
203 .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
204 .collect::<Result<Vec<_>, _>>()?;
205 Ok(CompiledRules {
206 collection: RuleCollection::try_new(configs)?,
207 by_id: ast_rules
208 .iter()
209 .copied()
210 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
211 .collect::<HashMap<_, _>>(),
212 metrics: metric_rules
213 .into_iter()
214 .map(build_metric_rule)
215 .collect::<Result<Vec<_>, _>>()?,
216 cross_file,
217 test_paths,
218 })
219}
220
221fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
222 let mut builder = GlobSetBuilder::new();
223 for pattern in patterns {
224 builder.add(Glob::new(pattern)?);
225 }
226 builder.build()
227}
228
229fn metric_findings<D: Doc>(
235 metrics: &[&MetricRule],
236 path: &Path,
237 root: &AstGrep<D>,
238 source: &str,
239 lang: &Language,
240 test_paths: &TestPaths,
241 cfg_test: Option<&CfgTestRanges>,
242) -> Vec<Finding> {
243 metrics
244 .iter()
245 .filter(|m| !(m.rule.skip_test_code && test_paths.is_test(path)))
246 .filter(|m| {
247 m.min_lines
248 .is_none_or(|floor| source.lines().count() >= floor)
249 })
250 .filter_map(|m| {
251 let exclude = m.rule.skip_test_code.then_some(cfg_test).flatten();
254 let value = metric::compute(m.metric, lang, root, source, exclude);
255 if !metric::exceeds(value, m.threshold) {
256 return None;
257 }
258 let rendered = m.metric.format_value(value);
259 Some(Finding {
260 rule_id: m.rule.id.clone(),
261 severity: m.rule.severity.clone(),
262 category: m.rule.category.clone().unwrap_or_default(),
263 message: m.rule.message.replace("$value", &rendered),
264 note: m.rule.note.clone(),
265 fix: m.rule.fix.clone(),
266 file: path.to_path_buf(),
267 line: 1,
268 column: 1,
269 end_line: 1,
270 end_column: 1,
271 matched_text: m.metric.describe(value),
272 confidence: None,
273 escalated: false,
274 })
275 })
276 .collect()
277}
278
279#[derive(Default)]
283struct FileScan {
284 findings: Vec<Finding>,
285 symbols: FileSymbols,
286}
287
288fn scan_file(
289 path: &Path,
290 lang: SupportLang,
291 rules: &CompiledRules,
292 collect_symbols: bool,
293) -> FileScan {
294 let rule_lang = support_lang_to_language(lang);
295 let applicable = rules.collection.get_rule_from_lang(path, lang);
296 let metrics: Vec<&MetricRule> = rules
297 .metrics
298 .iter()
299 .filter(|m| m.applies_to(path, &rule_lang))
300 .collect();
301 let want_symbols = collect_symbols && lang == SupportLang::Rust;
304 if applicable.is_empty() && metrics.is_empty() && !want_symbols {
305 return FileScan::default();
306 }
307 let Ok(source) = read_to_string(path) else {
308 return FileScan::default();
309 };
310
311 let root = lang.ast_grep(&source);
312 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
314
315 let mut findings: Vec<Finding> = if applicable.is_empty() {
316 Vec::new()
317 } else {
318 let combined = CombinedScan::new(applicable);
319 let result = combined.scan(&root, false);
320
321 let cfg_test = cfg_test.as_ref();
322 let is_test_file = rules.test_paths.is_test(path);
323
324 result
325 .matches
326 .into_iter()
327 .filter_map(|(config, matches)| {
328 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
329 Some((*rule, matches))
330 })
331 .flat_map(|(rule, matches)| {
332 let skip_test_code = rule.skip_test_code;
333 matches
334 .into_iter()
335 .map(move |node_match| {
336 let start = node_match.start_pos();
337 let end = node_match.end_pos();
338 Finding {
339 rule_id: rule.id.clone(),
340 severity: rule.severity.clone(),
341 category: rule.category.clone().unwrap_or_default(),
342 message: rule.message.clone(),
343 note: rule.note.clone(),
344 fix: rule.fix.clone(),
345 file: path.to_path_buf(),
346 line: start.line() + 1,
347 column: start.byte_point().1 + 1,
348 end_line: end.line() + 1,
349 end_column: end.byte_point().1 + 1,
350 matched_text: node_match.text().to_string(),
351 confidence: None,
352 escalated: false,
353 }
354 })
355 .filter(move |f| {
356 if !skip_test_code {
357 return true;
358 }
359 let in_test_code =
360 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
361 !in_test_code
362 })
363 })
364 .collect()
365 };
366
367 findings.extend(metric_findings(
368 &metrics,
369 path,
370 &root,
371 &source,
372 &rule_lang,
373 &rules.test_paths,
374 cfg_test.as_ref(),
375 ));
376
377 let symbols = match (want_symbols, cfg_test.as_ref()) {
380 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
381 _ => FileSymbols::default(),
382 };
383
384 FileScan {
385 findings: filter_disabled(findings, &source),
386 symbols,
387 }
388}
389
390fn cross_file_findings(
394 rules: &[CrossFileRule<'_>],
395 contributions: &[(PathBuf, FileSymbols)],
396) -> Vec<Finding> {
397 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
398 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
399 for rule in rules {
400 let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
401 for finding in found {
402 by_file
403 .entry(finding.file.clone())
404 .or_default()
405 .push(finding);
406 }
407 }
408 by_file
409 .into_iter()
410 .flat_map(|(path, findings)| match read_to_string(&path) {
411 Ok(source) => filter_disabled(findings, &source),
414 Err(_) => findings,
415 })
416 .collect()
417}
418
419fn append_cross_file(
425 findings: &mut Vec<Finding>,
426 compiled: &CompiledRules,
427 run_cross_file: bool,
428 contributions: &[(PathBuf, FileSymbols)],
429) {
430 if run_cross_file && !compiled.cross_file.is_empty() {
431 findings.extend(cross_file_findings(&compiled.cross_file, contributions));
432 }
433}
434
435fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
437 paths
438 .iter()
439 .flat_map(|path| WalkBuilder::new(path).build().flatten())
440 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
441 .map(|entry| entry.into_path())
442 .filter(|path| !ignores.is_match(path))
443 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
444 .collect()
445}
446
447fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
453 files
454 .iter()
455 .filter(|path| path.is_file())
456 .filter(|path| !ignores.is_match(path))
457 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
458 .collect()
459}
460
461#[cfg(test)]
462mod feature_tests;
463#[cfg(test)]
464mod scan_tests;
465#[cfg(test)]
466mod test_support;