use crate::{RuleConfig, SerializableRule, SerializableRuleConfig, SerializableRuleCore, Severity};
use ast_grep_core::language::Language;
use ast_grep_core::matcher::{Matcher, MatcherExt};
use ast_grep_core::{AstGrep, Doc, Node, NodeMatch};
use std::collections::{HashMap, HashSet};
pub struct ScanResult<'t, 'r, D: Doc, L: Language> {
pub diffs: Vec<(&'r RuleConfig<L>, NodeMatch<'t, D>)>,
pub matches: Vec<(&'r RuleConfig<L>, Vec<NodeMatch<'t, D>>)>,
}
struct ScanResultInner<'t, D: Doc> {
diffs: Vec<(usize, NodeMatch<'t, D>)>,
matches: HashMap<usize, Vec<NodeMatch<'t, D>>>,
unused_suppressions: Vec<NodeMatch<'t, D>>,
suppress_all_nodes: Vec<NodeMatch<'t, D>>,
}
impl<'t, D: Doc> ScanResultInner<'t, D> {
pub fn into_result<'r, L: Language>(
self,
combined: &CombinedScan<'r, L>,
separate_fix: bool,
) -> ScanResult<'t, 'r, D, L> {
let mut diffs: Vec<_> = self
.diffs
.into_iter()
.map(|(idx, nm)| (combined.get_rule(idx), nm))
.collect();
let mut matches: Vec<_> = self
.matches
.into_iter()
.map(|(idx, nms)| (combined.get_rule(idx), nms))
.collect();
if let Some(rule) = combined.unused_suppression_rule {
if separate_fix {
diffs.extend(self.unused_suppressions.into_iter().map(|nm| (rule, nm)));
diffs.sort_unstable_by_key(|(_, nm)| nm.range().start);
} else if !self.unused_suppressions.is_empty() {
let mut supprs = self.unused_suppressions;
supprs.sort_unstable_by_key(|nm| nm.range().start);
matches.push((rule, supprs));
}
}
if let Some(rule) = combined.no_suppress_all_rule {
if !self.suppress_all_nodes.is_empty() {
let mut supprs = self.suppress_all_nodes;
supprs.sort_unstable_by_key(|nm| nm.range().start);
matches.push((rule, supprs));
}
}
ScanResult { diffs, matches }
}
}
enum SuppressKind {
File,
Line(usize),
}
fn get_suppression_kind(node: &Node<'_, impl Doc>) -> Option<SuppressKind> {
if !node.kind().contains("comment") || !node.text().contains(IGNORE_TEXT) {
return None;
}
let line = node.start_pos().line();
let suppress_next_line = if let Some(prev) = node.prev() {
prev.start_pos().line() != line
} else {
true
};
if line == 0
&& suppress_next_line
&& node
.next()
.map(|next| next.start_pos().line() >= 2)
.unwrap_or(true)
{
return Some(SuppressKind::File);
}
let key = if suppress_next_line { line + 1 } else { line };
Some(SuppressKind::Line(key))
}
struct Suppressions {
file: Option<Suppression>,
lines: HashMap<usize, Suppression>,
}
impl Suppressions {
fn collect_all<D: Doc>(root: &AstGrep<D>) -> (Self, HashMap<usize, Node<'_, D>>) {
let mut suppressions = Self {
file: None,
lines: HashMap::new(),
};
let mut suppression_nodes = HashMap::new();
for node in root.root().dfs() {
let is_all_suppressed = suppressions.collect(&node, &mut suppression_nodes);
if is_all_suppressed {
break;
}
}
(suppressions, suppression_nodes)
}
fn collect<'r, D: Doc>(
&mut self,
node: &Node<'r, D>,
suppression_nodes: &mut HashMap<usize, Node<'r, D>>,
) -> bool {
let Some(sup) = get_suppression_kind(node) else {
return false;
};
let suppressed = Suppression {
suppressed: parse_suppression_set(&node.text()),
node_id: node.node_id(),
};
suppression_nodes.insert(node.node_id(), node.clone());
match sup {
SuppressKind::File => {
let is_all_suppressed = suppressed.suppressed.is_none();
self.file = Some(suppressed);
is_all_suppressed
}
SuppressKind::Line(key) => {
self.lines.insert(
key,
Suppression {
suppressed: parse_suppression_set(&node.text()),
node_id: node.node_id(),
},
);
false
}
}
}
fn suppress_all_node_ids(&self) -> impl Iterator<Item = usize> + '_ {
self
.file
.iter()
.chain(self.lines.values())
.filter(|s| s.suppressed.is_none())
.map(|s| s.node_id)
}
fn file_suppression(&self) -> MaySuppressed<'_> {
if let Some(sup) = &self.file {
MaySuppressed::Yes(sup)
} else {
MaySuppressed::No
}
}
fn line_suppression<D: Doc>(&self, node: &Node<'_, D>) -> MaySuppressed<'_> {
let line = node.start_pos().line();
if let Some(sup) = self.lines.get(&line) {
MaySuppressed::Yes(sup)
} else {
MaySuppressed::No
}
}
}
struct Suppression {
suppressed: Option<HashSet<String>>,
node_id: usize,
}
enum MaySuppressed<'a> {
Yes(&'a Suppression),
No,
}
impl MaySuppressed<'_> {
fn suppressed_id(&self, rule_id: &str) -> Option<usize> {
let suppression = match self {
MaySuppressed::No => return None,
MaySuppressed::Yes(s) => s,
};
if let Some(set) = &suppression.suppressed {
if set.contains(rule_id) {
Some(suppression.node_id)
} else {
None
}
} else {
Some(suppression.node_id)
}
}
}
const IGNORE_TEXT: &str = "ast-grep-ignore";
pub const UNUSED_SUPPRESSION_ID: &str = "unused-suppression";
pub const NO_SUPPRESS_ALL_ID: &str = "no-suppress-all";
pub struct CombinedScan<'r, L: Language> {
rules: Vec<&'r RuleConfig<L>>,
kind_rule_mapping: Vec<Vec<usize>>,
unused_suppression_rule: Option<&'r RuleConfig<L>>,
no_suppress_all_rule: Option<&'r RuleConfig<L>>,
}
impl<'r, L: Language> CombinedScan<'r, L> {
pub fn new(mut rules: Vec<&'r RuleConfig<L>>) -> Self {
rules.sort_unstable_by_key(|r| (r.fix.is_some(), &r.id));
let mut mapping = Vec::new();
for (idx, rule) in rules.iter().enumerate() {
let Some(kinds) = rule.matcher.potential_kinds() else {
eprintln!("rule `{}` must have kind", &rule.id);
continue;
};
for kind in &kinds {
while mapping.len() <= kind {
mapping.push(vec![]);
}
mapping[kind].push(idx);
}
}
Self {
rules,
kind_rule_mapping: mapping,
unused_suppression_rule: None,
no_suppress_all_rule: None,
}
}
pub fn set_no_suppress_all_rule(&mut self, rule: &'r RuleConfig<L>) {
if matches!(rule.severity, Severity::Off) {
return;
}
self.no_suppress_all_rule = Some(rule);
}
pub fn set_unused_suppression_rule(&mut self, rule: &'r RuleConfig<L>) {
if matches!(rule.severity, Severity::Off) {
return;
}
self.unused_suppression_rule = Some(rule);
}
pub fn scan<'a, D>(&self, root: &'a AstGrep<D>, separate_fix: bool) -> ScanResult<'a, '_, D, L>
where
D: Doc<Lang = L>,
{
let mut result = ScanResultInner {
diffs: vec![],
matches: HashMap::new(),
unused_suppressions: vec![],
suppress_all_nodes: vec![],
};
let (suppressions, mut suppression_nodes) = Suppressions::collect_all(root);
if self.no_suppress_all_rule.is_some() {
let nodes = suppressions
.suppress_all_node_ids()
.filter_map(|id| suppression_nodes.get(&id));
result
.suppress_all_nodes
.extend(nodes.cloned().map(NodeMatch::from));
}
let file_sup = suppressions.file_suppression();
if let MaySuppressed::Yes(s) = file_sup {
if s.suppressed.is_none() {
return result.into_result(self, separate_fix);
}
}
for node in root.root().dfs() {
let kind = node.kind_id() as usize;
let Some(rule_idx) = self.kind_rule_mapping.get(kind) else {
continue;
};
let line_sup = suppressions.line_suppression(&node);
for &idx in rule_idx {
let rule = &self.rules[idx];
let Some(ret) = rule.matcher.match_node(node.clone()) else {
continue;
};
if let Some(id) = file_sup.suppressed_id(&rule.id) {
suppression_nodes.remove(&id);
continue;
}
if let Some(id) = line_sup.suppressed_id(&rule.id) {
suppression_nodes.remove(&id);
continue;
}
if rule.fix.is_none() || !separate_fix {
let matches = result.matches.entry(idx).or_default();
matches.push(ret);
} else {
result.diffs.push((idx, ret));
}
}
}
result.unused_suppressions = suppression_nodes
.into_values()
.map(NodeMatch::from)
.collect();
result.into_result(self, separate_fix)
}
pub fn get_rule(&self, idx: usize) -> &'r RuleConfig<L> {
self.rules[idx]
}
pub fn no_suppress_all_config(severity: Severity, lang: L) -> RuleConfig<L> {
let config = SerializableRuleConfig {
id: NO_SUPPRESS_ALL_ID.into(),
severity,
message: "ast-grep-ignore must specify rule IDs.".into(),
note: Some("Use 'ast-grep-ignore: rule-id' to suppress specific rules.".into()),
..Self::builtin_config(lang)
};
RuleConfig::try_from(config, &Default::default()).unwrap()
}
pub fn unused_config(severity: Severity, lang: L) -> RuleConfig<L> {
let mut config = SerializableRuleConfig {
id: UNUSED_SUPPRESSION_ID.into(),
severity,
message: "Unused 'ast-grep-ignore' directive.".into(),
..Self::builtin_config(lang)
};
config.core.fix = crate::from_str(r#"''"#).unwrap();
RuleConfig::try_from(config, &Default::default()).unwrap()
}
fn builtin_config(lang: L) -> SerializableRuleConfig<L> {
let rule: SerializableRule = crate::from_str(r#"{"any": []}"#).unwrap();
SerializableRuleConfig {
core: SerializableRuleCore {
rule,
constraints: None,
fix: None,
transform: None,
utils: None,
},
language: lang,
id: String::new(),
severity: Severity::default(),
message: String::new(),
note: None,
files: None,
ignores: None,
rewriters: None,
url: None,
metadata: None,
labels: None,
}
}
}
fn trim_comment_trailing(text: &str) -> Option<&str> {
text
.trim_start() .split(' ') .next() }
fn parse_suppression_set(text: &str) -> Option<HashSet<String>> {
let (_, after) = text.trim().split_once(IGNORE_TEXT)?;
let after = after.trim();
if after.is_empty() {
return None;
}
let (_, rules) = after.split_once(':')?;
let set = rules
.split(',')
.flat_map(trim_comment_trailing)
.map(ToString::to_string)
.collect();
Some(set)
}
#[cfg(test)]
mod test {
use super::*;
use crate::from_str;
use crate::test::TypeScript;
use crate::SerializableRuleConfig;
use ast_grep_core::tree_sitter::{LanguageExt, StrDoc};
fn create_rule() -> RuleConfig<TypeScript> {
let rule: SerializableRuleConfig<TypeScript> = from_str(
r"
id: test
rule: {pattern: 'console.log($A)'}
language: Tsx",
)
.expect("parse");
RuleConfig::try_from(rule, &Default::default()).expect("work")
}
fn test_scan<F>(source: &str, test_fn: F)
where
F: Fn(
Vec<(
&'_ RuleConfig<TypeScript>,
Vec<NodeMatch<'_, StrDoc<TypeScript>>>,
)>,
),
{
let root = TypeScript::Tsx.ast_grep(source);
let rule = create_rule();
let rules = vec![&rule];
let scan = CombinedScan::new(rules);
let scanned = scan.scan(&root, false);
test_fn(scanned.matches);
}
#[test]
fn test_ignore_node() {
let source = r#"
// ast-grep-ignore
console.log('ignored all')
console.log('no ignore')
// ast-grep-ignore: test
console.log('ignore one')
// ast-grep-ignore: not-test
console.log('ignore another')
// ast-grep-ignore: not-test, test
console.log('multiple ignore')
"#;
test_scan(source, |scanned| {
let matches = &scanned[0];
assert_eq!(matches.1.len(), 2);
assert_eq!(matches.1[0].text(), "console.log('no ignore')");
assert_eq!(matches.1[1].text(), "console.log('ignore another')");
});
}
#[test]
fn test_ignore_node_same_line() {
let source = r#"
console.log('ignored all') // ast-grep-ignore
console.log('no ignore')
console.log('ignore one') // ast-grep-ignore: test
console.log('ignore another') // ast-grep-ignore: not-test
console.log('multiple ignore') // ast-grep-ignore: not-test, test
"#;
test_scan(source, |scanned| {
let matches = &scanned[0];
assert_eq!(matches.1.len(), 2);
assert_eq!(matches.1[0].text(), "console.log('no ignore')");
assert_eq!(matches.1[1].text(), "console.log('ignore another')");
});
}
#[test]
fn test_ignore_node_block_comment() {
let source = r#"
/* ast-grep-ignore: test */
console.log('ignore one')
/* ast-grep-ignore: not-test */
console.log('ignore another')
/* ast-grep-ignore: not-test, test */
console.log('multiple ignore')
console.log('no ignore')
"#;
test_scan(source, |scanned| {
let matches = &scanned[0];
assert_eq!(matches.1.len(), 2);
assert_eq!(matches.1[0].text(), "console.log('ignore another')");
assert_eq!(matches.1[1].text(), "console.log('no ignore')");
});
}
#[test]
fn test_parse_suppression_set_trims_block_comment_trailing() {
let set = parse_suppression_set("/* ast-grep-ignore: test */").expect("should parse");
assert!(set.contains("test"));
assert!(!set.contains("test */"));
let set = parse_suppression_set("/* ast-grep-ignore: not-test, test */").expect("should parse");
assert!(set.contains("not-test"));
assert!(set.contains("test"));
assert!(!set.contains("test */"));
}
fn test_scan_unused<F>(source: &str, test_fn: F)
where
F: Fn(
Vec<(
&'_ RuleConfig<TypeScript>,
Vec<NodeMatch<'_, StrDoc<TypeScript>>>,
)>,
),
{
let root = TypeScript::Tsx.ast_grep(source);
let rule = create_rule();
let rules = vec![&rule];
let mut scan = CombinedScan::new(rules);
let mut unused = create_rule();
unused.id = UNUSED_SUPPRESSION_ID.to_string();
scan.set_unused_suppression_rule(&unused);
let scanned = scan.scan(&root, false);
test_fn(scanned.matches);
}
#[test]
fn test_non_used_suppression() {
let source = r#"
console.log('no ignore')
console.debug('not used') // ast-grep-ignore: test
console.log('multiple ignore') // ast-grep-ignore: test
"#;
test_scan_unused(source, |scanned| {
assert_eq!(scanned.len(), 2);
let unused = &scanned[1];
assert_eq!(unused.1.len(), 1);
assert_eq!(unused.1[0].text(), "// ast-grep-ignore: test");
});
}
#[test]
fn test_file_suppression() {
let source = r#"// ast-grep-ignore: test
console.log('ignored')
console.debug('report') // ast-grep-ignore: test
console.log('report') // ast-grep-ignore: test
"#;
test_scan_unused(source, |scanned| {
assert_eq!(scanned.len(), 1);
let unused = &scanned[0];
assert_eq!(unused.1.len(), 2);
});
let source = r#"// ast-grep-ignore: test
console.debug('above is not file sup')
console.log('not ignored')
"#;
test_scan_unused(source, |scanned| {
assert_eq!(scanned.len(), 2);
assert_eq!(scanned[0].0.id, "test");
assert_eq!(scanned[1].0.id, UNUSED_SUPPRESSION_ID);
});
}
fn test_scan_no_suppress_all<F>(source: &str, test_fn: F)
where
F: Fn(
Vec<(
&'_ RuleConfig<TypeScript>,
Vec<NodeMatch<'_, StrDoc<TypeScript>>>,
)>,
),
{
let root = TypeScript::Tsx.ast_grep(source);
let rule = create_rule();
let rules = vec![&rule];
let mut scan = CombinedScan::new(rules);
let no_suppress_all = CombinedScan::no_suppress_all_config(Severity::Warning, TypeScript::Tsx);
scan.set_no_suppress_all_rule(&no_suppress_all);
let scanned = scan.scan(&root, false);
test_fn(scanned.matches);
}
#[test]
fn test_no_suppress_all_bare() {
let source = r#"
// ast-grep-ignore
console.log('ignored all')
console.log('no ignore')
"#;
test_scan_no_suppress_all(source, |scanned| {
let no_sup_all: Vec<_> = scanned
.iter()
.filter(|(r, _)| r.id == NO_SUPPRESS_ALL_ID)
.collect();
assert_eq!(no_sup_all.len(), 1);
assert_eq!(no_sup_all[0].1.len(), 1);
assert_eq!(no_sup_all[0].1[0].text(), "// ast-grep-ignore");
});
}
#[test]
fn test_no_suppress_all_missing_colon() {
let source = r#"
// ast-grep-ignore test
console.log('ignored all')
"#;
test_scan_no_suppress_all(source, |scanned| {
let no_sup_all: Vec<_> = scanned
.iter()
.filter(|(r, _)| r.id == NO_SUPPRESS_ALL_ID)
.collect();
assert_eq!(no_sup_all.len(), 1);
assert_eq!(no_sup_all[0].1.len(), 1);
});
}
#[test]
fn test_no_suppress_all_with_colon_does_not_fire() {
let source = r#"
// ast-grep-ignore: test
console.log('ignored specific')
console.log('no ignore')
"#;
test_scan_no_suppress_all(source, |scanned| {
let no_sup_all: Vec<_> = scanned
.iter()
.filter(|(r, _)| r.id == NO_SUPPRESS_ALL_ID)
.collect();
assert_eq!(no_sup_all.len(), 0);
});
}
#[test]
fn test_no_suppress_all_same_line() {
let source = r#"
console.log('ignored') // ast-grep-ignore
console.log('no ignore')
"#;
test_scan_no_suppress_all(source, |scanned| {
let no_sup_all: Vec<_> = scanned
.iter()
.filter(|(r, _)| r.id == NO_SUPPRESS_ALL_ID)
.collect();
assert_eq!(no_sup_all.len(), 1);
assert_eq!(no_sup_all[0].1.len(), 1);
});
}
#[test]
fn test_no_suppress_all_file_level() {
let source = r#"// ast-grep-ignore
console.log('ignored')
"#;
test_scan_no_suppress_all(source, |scanned| {
let no_sup_all: Vec<_> = scanned
.iter()
.filter(|(r, _)| r.id == NO_SUPPRESS_ALL_ID)
.collect();
assert_eq!(no_sup_all.len(), 1);
});
}
#[test]
fn test_file_suppression_all() {
let source = r#"// ast-grep-ignore
console.log('ignored')
console.debug('report') // ast-grep-ignore: test
console.log('report') // ast-grep-ignore
"#;
test_scan_unused(source, |scanned| {
assert_eq!(scanned.len(), 0);
});
let source = r#"// ast-grep-ignore
console.debug('no hit')
"#;
test_scan_unused(source, |scanned| {
assert_eq!(scanned.len(), 0);
});
}
}