1use crate::{RuleConfig, SerializableRule, SerializableRuleConfig, SerializableRuleCore, Severity};
2
3use ast_grep_core::language::Language;
4use ast_grep_core::matcher::{Matcher, MatcherExt};
5use ast_grep_core::{AstGrep, Doc, Node, NodeMatch};
6
7use std::collections::{HashMap, HashSet};
8
9pub struct ScanResult<'t, 'r, D: Doc, L: Language> {
10 pub diffs: Vec<(&'r RuleConfig<L>, NodeMatch<'t, D>)>,
11 pub matches: Vec<(&'r RuleConfig<L>, Vec<NodeMatch<'t, D>>)>,
12}
13
14struct ScanResultInner<'t, D: Doc> {
17 diffs: Vec<(usize, NodeMatch<'t, D>)>,
18 matches: HashMap<usize, Vec<NodeMatch<'t, D>>>,
19 unused_suppressions: Vec<NodeMatch<'t, D>>,
20}
21
22impl<'t, D: Doc> ScanResultInner<'t, D> {
23 pub fn into_result<'r, L: Language>(
24 self,
25 combined: &CombinedScan<'r, L>,
26 separate_fix: bool,
27 ) -> ScanResult<'t, 'r, D, L> {
28 let mut diffs: Vec<_> = self
29 .diffs
30 .into_iter()
31 .map(|(idx, nm)| (combined.get_rule(idx), nm))
32 .collect();
33 let mut matches: Vec<_> = self
34 .matches
35 .into_iter()
36 .map(|(idx, nms)| (combined.get_rule(idx), nms))
37 .collect();
38 if let Some(rule) = combined.unused_suppression_rule {
39 if separate_fix {
40 diffs.extend(self.unused_suppressions.into_iter().map(|nm| (rule, nm)));
41 diffs.sort_unstable_by_key(|(_, nm)| nm.range().start);
42 } else if !self.unused_suppressions.is_empty() {
43 let mut supprs = self.unused_suppressions;
45 supprs.sort_unstable_by_key(|nm| nm.range().start);
46 matches.push((rule, supprs));
47 }
48 }
49 ScanResult { diffs, matches }
50 }
51}
52
53enum SuppressKind {
54 File,
56 Line(usize),
58}
59
60fn get_suppression_kind(node: &Node<'_, impl Doc>) -> Option<SuppressKind> {
61 if !node.kind().contains("comment") || !node.text().contains(IGNORE_TEXT) {
62 return None;
63 }
64 let line = node.start_pos().line();
65 let suppress_next_line = if let Some(prev) = node.prev() {
66 prev.start_pos().line() != line
67 } else {
68 true
69 };
70 if line == 0
73 && suppress_next_line
74 && node
75 .next()
76 .map(|next| next.start_pos().line() >= 2)
77 .unwrap_or(true)
78 {
79 return Some(SuppressKind::File);
80 }
81 let key = if suppress_next_line { line + 1 } else { line };
82 Some(SuppressKind::Line(key))
83}
84
85struct Suppressions {
86 file: Option<Suppression>,
87 lines: HashMap<usize, Suppression>,
89}
90
91impl Suppressions {
92 fn collect_all<D: Doc>(root: &AstGrep<D>) -> (Self, HashMap<usize, Node<'_, D>>) {
93 let mut suppressions = Self {
94 file: None,
95 lines: HashMap::new(),
96 };
97 let mut suppression_nodes = HashMap::new();
98 for node in root.root().dfs() {
99 let is_all_suppressed = suppressions.collect(&node, &mut suppression_nodes);
100 if is_all_suppressed {
101 break;
102 }
103 }
104 (suppressions, suppression_nodes)
105 }
106 fn collect<'r, D: Doc>(
110 &mut self,
111 node: &Node<'r, D>,
112 suppression_nodes: &mut HashMap<usize, Node<'r, D>>,
113 ) -> bool {
114 let Some(sup) = get_suppression_kind(node) else {
115 return false;
116 };
117 let suppressed = Suppression {
118 suppressed: parse_suppression_set(&node.text()),
119 node_id: node.node_id(),
120 };
121 suppression_nodes.insert(node.node_id(), node.clone());
122 match sup {
123 SuppressKind::File => {
124 let is_all_suppressed = suppressed.suppressed.is_none();
125 self.file = Some(suppressed);
126 is_all_suppressed
127 }
128 SuppressKind::Line(key) => {
129 self.lines.insert(
130 key,
131 Suppression {
132 suppressed: parse_suppression_set(&node.text()),
133 node_id: node.node_id(),
134 },
135 );
136 false
137 }
138 }
139 }
140
141 fn file_suppression(&self) -> MaySuppressed<'_> {
142 if let Some(sup) = &self.file {
143 MaySuppressed::Yes(sup)
144 } else {
145 MaySuppressed::No
146 }
147 }
148
149 fn line_suppression<D: Doc>(&self, node: &Node<'_, D>) -> MaySuppressed<'_> {
150 let line = node.start_pos().line();
151 if let Some(sup) = self.lines.get(&line) {
152 MaySuppressed::Yes(sup)
153 } else {
154 MaySuppressed::No
155 }
156 }
157}
158
159struct Suppression {
160 suppressed: Option<HashSet<String>>,
162 node_id: usize,
163}
164
165enum MaySuppressed<'a> {
166 Yes(&'a Suppression),
167 No,
168}
169
170impl MaySuppressed<'_> {
171 fn suppressed_id(&self, rule_id: &str) -> Option<usize> {
172 let suppression = match self {
173 MaySuppressed::No => return None,
174 MaySuppressed::Yes(s) => s,
175 };
176 if let Some(set) = &suppression.suppressed {
177 if set.contains(rule_id) {
178 Some(suppression.node_id)
179 } else {
180 None
181 }
182 } else {
183 Some(suppression.node_id)
184 }
185 }
186}
187
188const IGNORE_TEXT: &str = "ast-grep-ignore";
189
190pub struct CombinedScan<'r, L: Language> {
194 rules: Vec<&'r RuleConfig<L>>,
195 kind_rule_mapping: Vec<Vec<usize>>,
197 unused_suppression_rule: Option<&'r RuleConfig<L>>,
199}
200
201impl<'r, L: Language> CombinedScan<'r, L> {
202 pub fn new(mut rules: Vec<&'r RuleConfig<L>>) -> Self {
203 rules.sort_unstable_by_key(|r| (r.fix.is_some(), &r.id));
206 let mut mapping = Vec::new();
207 for (idx, rule) in rules.iter().enumerate() {
208 let Some(kinds) = rule.matcher.potential_kinds() else {
209 eprintln!("rule `{}` must have kind", &rule.id);
210 continue;
211 };
212 for kind in &kinds {
213 while mapping.len() <= kind {
217 mapping.push(vec![]);
218 }
219 mapping[kind].push(idx);
220 }
221 }
222 Self {
223 rules,
224 kind_rule_mapping: mapping,
225 unused_suppression_rule: None,
226 }
227 }
228
229 pub fn set_unused_suppression_rule(&mut self, rule: &'r RuleConfig<L>) {
230 if matches!(rule.severity, Severity::Off) {
231 return;
232 }
233 self.unused_suppression_rule = Some(rule);
234 }
235
236 pub fn scan<'a, D>(&self, root: &'a AstGrep<D>, separate_fix: bool) -> ScanResult<'a, '_, D, L>
237 where
238 D: Doc<Lang = L>,
239 {
240 let mut result = ScanResultInner {
241 diffs: vec![],
242 matches: HashMap::new(),
243 unused_suppressions: vec![],
244 };
245 let (suppressions, mut suppression_nodes) = Suppressions::collect_all(root);
246 let file_sup = suppressions.file_suppression();
247 if let MaySuppressed::Yes(s) = file_sup {
248 if s.suppressed.is_none() {
249 return result.into_result(self, separate_fix);
250 }
251 }
252 for node in root.root().dfs() {
253 let kind = node.kind_id() as usize;
254 let Some(rule_idx) = self.kind_rule_mapping.get(kind) else {
255 continue;
256 };
257 let line_sup = suppressions.line_suppression(&node);
258 for &idx in rule_idx {
259 let rule = &self.rules[idx];
260 let Some(ret) = rule.matcher.match_node(node.clone()) else {
261 continue;
262 };
263 if let Some(id) = file_sup.suppressed_id(&rule.id) {
264 suppression_nodes.remove(&id);
265 continue;
266 }
267 if let Some(id) = line_sup.suppressed_id(&rule.id) {
268 suppression_nodes.remove(&id);
269 continue;
270 }
271 if rule.fix.is_none() || !separate_fix {
272 let matches = result.matches.entry(idx).or_default();
273 matches.push(ret);
274 } else {
275 result.diffs.push((idx, ret));
276 }
277 }
278 }
279 result.unused_suppressions = suppression_nodes
280 .into_values()
281 .map(NodeMatch::from)
282 .collect();
283 result.into_result(self, separate_fix)
284 }
285
286 pub fn get_rule(&self, idx: usize) -> &'r RuleConfig<L> {
287 self.rules[idx]
288 }
289
290 pub fn unused_config(severity: Severity, lang: L) -> RuleConfig<L> {
291 let rule: SerializableRule = crate::from_str(r#"{"any": []}"#).unwrap();
292 let core = SerializableRuleCore {
293 rule,
294 constraints: None,
295 fix: crate::from_str(r#"''"#).unwrap(),
296 transform: None,
297 utils: None,
298 };
299 let config = SerializableRuleConfig {
300 core,
301 id: "unused-suppression".to_string(),
302 severity,
303 files: None,
304 ignores: None,
305 language: lang,
306 message: "Unused 'ast-grep-ignore' directive.".into(),
307 metadata: None,
308 note: None,
309 rewriters: None,
310 url: None,
311 labels: None,
312 };
313 RuleConfig::try_from(config, &Default::default()).unwrap()
314 }
315}
316
317fn parse_suppression_set(text: &str) -> Option<HashSet<String>> {
318 let (_, after) = text.trim().split_once(IGNORE_TEXT)?;
319 let after = after.trim();
320 if after.is_empty() {
321 return None;
322 }
323 let (_, rules) = after.split_once(':')?;
324 let set = rules.split(',').map(|r| r.trim().to_string()).collect();
325 Some(set)
326}
327
328#[cfg(test)]
329mod test {
330 use super::*;
331 use crate::from_str;
332 use crate::test::TypeScript;
333 use crate::SerializableRuleConfig;
334 use ast_grep_core::tree_sitter::{LanguageExt, StrDoc};
335
336 fn create_rule() -> RuleConfig<TypeScript> {
337 let rule: SerializableRuleConfig<TypeScript> = from_str(
338 r"
339id: test
340rule: {pattern: 'console.log($A)'}
341language: Tsx",
342 )
343 .expect("parse");
344 RuleConfig::try_from(rule, &Default::default()).expect("work")
345 }
346
347 fn test_scan<F>(source: &str, test_fn: F)
348 where
349 F: Fn(
350 Vec<(
351 &'_ RuleConfig<TypeScript>,
352 Vec<NodeMatch<'_, StrDoc<TypeScript>>>,
353 )>,
354 ),
355 {
356 let root = TypeScript::Tsx.ast_grep(source);
357 let rule = create_rule();
358 let rules = vec![&rule];
359 let scan = CombinedScan::new(rules);
360 let scanned = scan.scan(&root, false);
361 test_fn(scanned.matches);
362 }
363
364 #[test]
365 fn test_ignore_node() {
366 let source = r#"
367 // ast-grep-ignore
368 console.log('ignored all')
369 console.log('no ignore')
370 // ast-grep-ignore: test
371 console.log('ignore one')
372 // ast-grep-ignore: not-test
373 console.log('ignore another')
374 // ast-grep-ignore: not-test, test
375 console.log('multiple ignore')
376 "#;
377 test_scan(source, |scanned| {
378 let matches = &scanned[0];
379 assert_eq!(matches.1.len(), 2);
380 assert_eq!(matches.1[0].text(), "console.log('no ignore')");
381 assert_eq!(matches.1[1].text(), "console.log('ignore another')");
382 });
383 }
384
385 #[test]
386 fn test_ignore_node_same_line() {
387 let source = r#"
388 console.log('ignored all') // ast-grep-ignore
389 console.log('no ignore')
390 console.log('ignore one') // ast-grep-ignore: test
391 console.log('ignore another') // ast-grep-ignore: not-test
392 console.log('multiple ignore') // ast-grep-ignore: not-test, test
393 "#;
394 test_scan(source, |scanned| {
395 let matches = &scanned[0];
396 assert_eq!(matches.1.len(), 2);
397 assert_eq!(matches.1[0].text(), "console.log('no ignore')");
398 assert_eq!(matches.1[1].text(), "console.log('ignore another')");
399 });
400 }
401
402 fn test_scan_unused<F>(source: &str, test_fn: F)
403 where
404 F: Fn(
405 Vec<(
406 &'_ RuleConfig<TypeScript>,
407 Vec<NodeMatch<'_, StrDoc<TypeScript>>>,
408 )>,
409 ),
410 {
411 let root = TypeScript::Tsx.ast_grep(source);
412 let rule = create_rule();
413 let rules = vec![&rule];
414 let mut scan = CombinedScan::new(rules);
415 let mut unused = create_rule();
416 unused.id = "unused-suppression".to_string();
417 scan.set_unused_suppression_rule(&unused);
418 let scanned = scan.scan(&root, false);
419 test_fn(scanned.matches);
420 }
421
422 #[test]
423 fn test_non_used_suppression() {
424 let source = r#"
425 console.log('no ignore')
426 console.debug('not used') // ast-grep-ignore: test
427 console.log('multiple ignore') // ast-grep-ignore: test
428 "#;
429 test_scan_unused(source, |scanned| {
430 assert_eq!(scanned.len(), 2);
431 let unused = &scanned[1];
432 assert_eq!(unused.1.len(), 1);
433 assert_eq!(unused.1[0].text(), "// ast-grep-ignore: test");
434 });
435 }
436
437 #[test]
438 fn test_file_suppression() {
439 let source = r#"// ast-grep-ignore: test
440
441 console.log('ignored')
442 console.debug('report') // ast-grep-ignore: test
443 console.log('report') // ast-grep-ignore: test
444 "#;
445 test_scan_unused(source, |scanned| {
446 assert_eq!(scanned.len(), 1);
447 let unused = &scanned[0];
448 assert_eq!(unused.1.len(), 2);
449 });
450 let source = r#"// ast-grep-ignore: test
451 console.debug('above is not file sup')
452 console.log('not ignored')
453 "#;
454 test_scan_unused(source, |scanned| {
455 assert_eq!(scanned.len(), 2);
456 assert_eq!(scanned[0].0.id, "test");
457 assert_eq!(scanned[1].0.id, "unused-suppression");
458 });
459 }
460
461 #[test]
462 fn test_file_suppression_all() {
463 let source = r#"// ast-grep-ignore
464
465 console.log('ignored')
466 console.debug('report') // ast-grep-ignore: test
467 console.log('report') // ast-grep-ignore
468 "#;
469 test_scan_unused(source, |scanned| {
470 assert_eq!(scanned.len(), 0);
471 });
472 let source = r#"// ast-grep-ignore
473
474 console.debug('no hit')
475 "#;
476 test_scan_unused(source, |scanned| {
477 assert_eq!(scanned.len(), 0);
478 });
479 }
480}