rubyfast/
comment_directives.rs1use 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#[derive(Debug)]
10pub struct DisabledSet {
11 all_disabled_lines: HashSet<usize>,
13 rule_disabled_lines: HashSet<(usize, OffenseKind)>,
15}
16
17impl DisabledSet {
18 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
24pub 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 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 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 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 let end_line = comment_line; 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 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
147fn parse_directive(comment: &str) -> Option<Directive> {
149 let stripped = comment.trim_start_matches('#').trim();
151
152 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
182fn 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
197fn is_trailing_comment(source: &[u8], begin: usize) -> bool {
199 let line_start = source[..begin]
201 .iter()
202 .rposition(|&b| b == b'\n')
203 .map(|p| p + 1)
204 .unwrap_or(0);
205
206 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}