Skip to main content

rubyfast/
comment_directives.rs

1use std::collections::HashSet;
2
3use lib_ruby_parser::source::Comment;
4
5use crate::ast_helpers::byte_offset_to_line;
6use crate::offense::OffenseKind;
7
8/// Tracks which lines have which offense kinds disabled via inline comments.
9#[derive(Debug)]
10pub struct DisabledSet {
11    /// Lines where all rules are disabled.
12    all_disabled_lines: HashSet<usize>,
13    /// Lines where specific rules are disabled.
14    rule_disabled_lines: HashSet<(usize, OffenseKind)>,
15}
16
17impl DisabledSet {
18    /// Check if a given offense kind is disabled on a given line.
19    pub fn is_disabled(&self, line: usize, kind: OffenseKind) -> bool {
20        self.all_disabled_lines.contains(&line) || self.rule_disabled_lines.contains(&(line, kind))
21    }
22}
23
24/// Build a DisabledSet from parser comments, source bytes, and pre-computed newline positions.
25///
26/// Supports:
27/// - `# rubyfast:disable rule` or `# fasterer:disable rule` — trailing (same line) or block start
28/// - `# rubyfast:disable-next-line rule` — disables the next line
29/// - `# rubyfast:enable rule` — ends a block disable
30/// - `# rubyfast:disable all` — disable all rules
31/// - `# rubyfast:disable rule1, rule2` — multiple rules
32pub fn build_disabled_set(
33    comments: &[Comment],
34    source: &[u8],
35    newline_positions: &[usize],
36) -> DisabledSet {
37    let total_lines = newline_positions.len() + 1;
38
39    let mut all_disabled_lines = HashSet::new();
40    let mut rule_disabled_lines = HashSet::new();
41
42    // Track block disables: None = all, Some(kind) = specific
43    let mut block_all_start: Option<usize> = None;
44    let mut block_rule_starts: Vec<(OffenseKind, usize)> = Vec::new();
45
46    for comment in comments {
47        let begin = comment.location.begin;
48        let end = comment.location.end;
49        let comment_line = byte_offset_to_line(newline_positions, begin);
50        let comment_text = &source[begin..end.min(source.len())];
51        let comment_str = String::from_utf8_lossy(comment_text);
52
53        let is_trailing = is_trailing_comment(source, begin);
54
55        if let Some(directive) = parse_directive(&comment_str) {
56            match directive {
57                Directive::Disable(targets) if is_trailing => {
58                    // Same-line disable
59                    apply_targets_to_line(
60                        &targets,
61                        comment_line,
62                        &mut all_disabled_lines,
63                        &mut rule_disabled_lines,
64                    );
65                }
66                Directive::DisableNextLine(targets) => {
67                    let next_line = comment_line + 1;
68                    apply_targets_to_line(
69                        &targets,
70                        next_line,
71                        &mut all_disabled_lines,
72                        &mut rule_disabled_lines,
73                    );
74                }
75                Directive::Disable(targets) => {
76                    // Standalone comment — block start
77                    for target in &targets {
78                        match target {
79                            Target::All => {
80                                block_all_start = Some(comment_line + 1);
81                            }
82                            Target::Rule(kind) => {
83                                block_rule_starts.push((*kind, comment_line + 1));
84                            }
85                        }
86                    }
87                }
88                Directive::Enable(targets) => {
89                    // Block end
90                    let end_line = comment_line; // exclusive
91                    for target in &targets {
92                        match target {
93                            Target::All => {
94                                if let Some(start) = block_all_start.take() {
95                                    for line in start..end_line {
96                                        all_disabled_lines.insert(line);
97                                    }
98                                }
99                            }
100                            Target::Rule(kind) => {
101                                let idx = block_rule_starts.iter().rposition(|(k, _)| k == kind);
102                                if let Some(i) = idx {
103                                    let (_, start) = block_rule_starts.remove(i);
104                                    for line in start..end_line {
105                                        rule_disabled_lines.insert((line, *kind));
106                                    }
107                                }
108                            }
109                        }
110                    }
111                }
112            }
113        }
114    }
115
116    // Close unclosed block disables at end of file
117    if let Some(start) = block_all_start {
118        for line in start..=total_lines {
119            all_disabled_lines.insert(line);
120        }
121    }
122    for (kind, start) in &block_rule_starts {
123        for line in *start..=total_lines {
124            rule_disabled_lines.insert((line, *kind));
125        }
126    }
127
128    DisabledSet {
129        all_disabled_lines,
130        rule_disabled_lines,
131    }
132}
133
134#[derive(Debug)]
135enum Target {
136    All,
137    Rule(OffenseKind),
138}
139
140#[derive(Debug)]
141enum Directive {
142    Disable(Vec<Target>),
143    DisableNextLine(Vec<Target>),
144    Enable(Vec<Target>),
145}
146
147/// Parse a comment string into a directive, if it matches.
148fn parse_directive(comment: &str) -> Option<Directive> {
149    // Strip leading `#` and whitespace
150    let stripped = comment.trim_start_matches('#').trim();
151
152    // Match `rubyfast:` or `fasterer:` prefix
153    let rest = stripped
154        .strip_prefix("rubyfast:")
155        .or_else(|| stripped.strip_prefix("fasterer:"))?;
156
157    let rest = rest.trim();
158
159    if let Some(targets_str) = rest.strip_prefix("disable-next-line") {
160        let targets = parse_targets(targets_str.trim());
161        if targets.is_empty() {
162            return None;
163        }
164        Some(Directive::DisableNextLine(targets))
165    } else if let Some(targets_str) = rest.strip_prefix("disable") {
166        let targets = parse_targets(targets_str.trim());
167        if targets.is_empty() {
168            return None;
169        }
170        Some(Directive::Disable(targets))
171    } else if let Some(targets_str) = rest.strip_prefix("enable") {
172        let targets = parse_targets(targets_str.trim());
173        if targets.is_empty() {
174            return None;
175        }
176        Some(Directive::Enable(targets))
177    } else {
178        None
179    }
180}
181
182/// Parse comma-separated targets: "all" or "rule1, rule2"
183fn parse_targets(s: &str) -> Vec<Target> {
184    s.split(',')
185        .map(|t| t.trim())
186        .filter(|t| !t.is_empty())
187        .filter_map(|t| {
188            if t == "all" {
189                Some(Target::All)
190            } else {
191                OffenseKind::from_config_key(t).map(Target::Rule)
192            }
193        })
194        .collect()
195}
196
197/// Check if a comment at `begin` byte offset is trailing (has code before it on the same line).
198fn is_trailing_comment(source: &[u8], begin: usize) -> bool {
199    // Walk backwards from begin to find the start of the line
200    let line_start = source[..begin]
201        .iter()
202        .rposition(|&b| b == b'\n')
203        .map(|p| p + 1)
204        .unwrap_or(0);
205
206    // Check if there's non-whitespace before the comment on this line
207    source[line_start..begin]
208        .iter()
209        .any(|&b| !b.is_ascii_whitespace())
210}
211
212fn apply_targets_to_line(
213    targets: &[Target],
214    line: usize,
215    all_disabled_lines: &mut HashSet<usize>,
216    rule_disabled_lines: &mut HashSet<(usize, OffenseKind)>,
217) {
218    for target in targets {
219        match target {
220            Target::All => {
221                all_disabled_lines.insert(line);
222            }
223            Target::Rule(kind) => {
224                rule_disabled_lines.insert((line, *kind));
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn parse_and_build(source: &str) -> DisabledSet {
235        let bytes = source.as_bytes().to_vec();
236        let result = lib_ruby_parser::Parser::new(bytes.clone(), Default::default()).do_parse();
237        let newline_positions: Vec<usize> = bytes
238            .iter()
239            .enumerate()
240            .filter(|(_, &b)| b == b'\n')
241            .map(|(i, _)| i)
242            .collect();
243        build_disabled_set(&result.comments, &bytes, &newline_positions)
244    }
245
246    #[test]
247    fn trailing_disable_same_line() {
248        let source = "x = [].shuffle.first # rubyfast:disable shuffle_first_vs_sample\ny = 1\n";
249        let set = parse_and_build(source);
250        assert!(set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
251        assert!(!set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
252    }
253
254    #[test]
255    fn disable_next_line() {
256        let source = "# rubyfast:disable-next-line shuffle_first_vs_sample\nx = [].shuffle.first\ny = [].shuffle.first\n";
257        let set = parse_and_build(source);
258        assert!(set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
259        assert!(!set.is_disabled(3, OffenseKind::ShuffleFirstVsSample));
260    }
261
262    #[test]
263    fn block_disable_enable() {
264        let source = "x = 1\n# rubyfast:disable for_loop_vs_each\nfor i in [1]; end\n# rubyfast:enable for_loop_vs_each\nfor j in [2]; end\n";
265        let set = parse_and_build(source);
266        assert!(set.is_disabled(3, OffenseKind::ForLoopVsEach));
267        assert!(!set.is_disabled(5, OffenseKind::ForLoopVsEach));
268    }
269
270    #[test]
271    fn disable_all() {
272        let source = "x = 1 # rubyfast:disable all\n";
273        let set = parse_and_build(source);
274        assert!(set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
275        assert!(set.is_disabled(1, OffenseKind::ForLoopVsEach));
276    }
277
278    #[test]
279    fn multiple_rules() {
280        let source = "x = 1 # rubyfast:disable shuffle_first_vs_sample, for_loop_vs_each\n";
281        let set = parse_and_build(source);
282        assert!(set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
283        assert!(set.is_disabled(1, OffenseKind::ForLoopVsEach));
284        assert!(!set.is_disabled(1, OffenseKind::GsubVsTr));
285    }
286
287    #[test]
288    fn fasterer_compat() {
289        let source = "x = 1 # fasterer:disable shuffle_first_vs_sample\n";
290        let set = parse_and_build(source);
291        assert!(set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
292    }
293
294    #[test]
295    fn unclosed_block_disable_extends_to_eof() {
296        let source = "# rubyfast:disable for_loop_vs_each\nfor i in [1]; end\nfor j in [2]; end\n";
297        let set = parse_and_build(source);
298        assert!(set.is_disabled(2, OffenseKind::ForLoopVsEach));
299        assert!(set.is_disabled(3, OffenseKind::ForLoopVsEach));
300    }
301
302    #[test]
303    fn unknown_rule_ignored() {
304        let source = "x = 1 # rubyfast:disable nonexistent_rule\n";
305        let set = parse_and_build(source);
306        assert!(!set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
307    }
308
309    #[test]
310    fn disable_next_line_all() {
311        let source = "# rubyfast:disable-next-line all\nx = [].shuffle.first\ny = 1\n";
312        let set = parse_and_build(source);
313        assert!(set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
314        assert!(set.is_disabled(2, OffenseKind::ForLoopVsEach));
315        assert!(!set.is_disabled(3, OffenseKind::ShuffleFirstVsSample));
316    }
317
318    #[test]
319    fn block_disable_all_and_enable_all() {
320        let source = "# rubyfast:disable all\nx = 1\ny = 2\n# rubyfast:enable all\nz = 3\n";
321        let set = parse_and_build(source);
322        assert!(set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
323        assert!(set.is_disabled(3, OffenseKind::ForLoopVsEach));
324        assert!(!set.is_disabled(5, OffenseKind::ShuffleFirstVsSample));
325    }
326
327    #[test]
328    fn multiple_rules_in_block_disable() {
329        let source = "# rubyfast:disable shuffle_first_vs_sample, for_loop_vs_each\nx = 1\n# rubyfast:enable shuffle_first_vs_sample, for_loop_vs_each\ny = 2\n";
330        let set = parse_and_build(source);
331        assert!(set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
332        assert!(set.is_disabled(2, OffenseKind::ForLoopVsEach));
333        assert!(!set.is_disabled(4, OffenseKind::ShuffleFirstVsSample));
334        assert!(!set.is_disabled(4, OffenseKind::ForLoopVsEach));
335    }
336
337    #[test]
338    fn unclosed_block_disable_all_extends_to_eof() {
339        let source = "# rubyfast:disable all\nx = 1\ny = 2\n";
340        let set = parse_and_build(source);
341        assert!(set.is_disabled(2, OffenseKind::ShuffleFirstVsSample));
342        assert!(set.is_disabled(3, OffenseKind::GsubVsTr));
343    }
344
345    #[test]
346    fn empty_disable_directive_ignored() {
347        let source = "x = 1 # rubyfast:disable\n";
348        let set = parse_and_build(source);
349        assert!(!set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
350    }
351
352    #[test]
353    fn empty_enable_directive_ignored() {
354        let source = "# rubyfast:enable\n";
355        let set = parse_and_build(source);
356        assert!(!set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
357    }
358
359    #[test]
360    fn is_trailing_at_start_of_file() {
361        let source = b"# comment\nx = 1\n";
362        assert!(!is_trailing_comment(source, 0));
363    }
364
365    #[test]
366    fn unrecognized_directive_action_ignored() {
367        let source = "x = 1 # rubyfast:freeze all\n";
368        let set = parse_and_build(source);
369        assert!(!set.is_disabled(1, OffenseKind::ShuffleFirstVsSample));
370    }
371
372    #[test]
373    fn enable_without_matching_disable_is_noop() {
374        let source = "# rubyfast:enable for_loop_vs_each\nfor x in [1]; end\n";
375        let set = parse_and_build(source);
376        assert!(!set.is_disabled(2, OffenseKind::ForLoopVsEach));
377    }
378}