ast_grep_config/
combined.rs

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
14/// store the index to the rule and the matched node
15/// it will be converted to ScanResult by resolving the rule
16struct 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        // do not push empty suppression to matches
44        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  /// suppress the whole file
55  File,
56  /// suppress specific line
57  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 the first line is suppressed and the next line is empyt,
71  // we suppress the whole file see gh #1541
72  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  /// line number which may be suppressed
88  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  /// collect all suppression nodes from the root node
107  /// returns if the whole file need to be suppressed, including unused sup
108  /// see #1541
109  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  /// None = suppress all
161  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
190/// A struct to group all rules according to their potential kinds.
191/// This can greatly reduce traversal times and skip unmatchable rules.
192/// Rules are referenced by their index in the rules vector.
193pub struct CombinedScan<'r, L: Language> {
194  rules: Vec<&'r RuleConfig<L>>,
195  /// a vec of vec, mapping from kind to a list of rule index
196  kind_rule_mapping: Vec<Vec<usize>>,
197  /// a rule for unused_suppressions
198  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    // process fixable rule first, the order by id
204    // note, mapping.push will invert order so we sort fixable order in reverse
205    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        // NOTE: common languages usually have about several hundred kinds
214        // from 200+ ~ 500+, it is okay to waste about 500 * 24 Byte vec size = 12kB
215        // see https://github.com/Wilfred/difftastic/tree/master/vendored_parsers
216        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}