code_moniker_check/check/
suppress.rs1use std::collections::HashSet;
2
3use regex::{Captures, Regex};
4
5use crate::check::eval::Violation;
6use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
7use code_moniker_core::core::kinds::KIND_COMMENT;
8
9pub fn apply(graph: &CodeGraph, source: &str, violations: Vec<Violation>) -> Vec<Violation> {
19 let directives = collect_directives(graph, source);
20 if directives.is_empty() {
21 return violations;
22 }
23
24 let file_scope: Vec<&Directive> = directives.iter().filter(|d| d.file_scope).collect();
25 let line_scope: Vec<(&Directive, Option<(u32, u32)>)> = directives
26 .iter()
27 .filter(|d| !d.file_scope)
28 .map(|d| (d, target_lines_for(graph, source, d)))
29 .collect();
30
31 violations
32 .into_iter()
33 .filter(|v| {
34 !file_scope.iter().any(|d| matches_id(d, &v.rule_id))
35 && !line_scope.iter().any(|(d, target)| {
36 matches_id(d, &v.rule_id)
37 && target.is_some_and(|(s, e)| v.lines.0 >= s && v.lines.0 <= e)
38 })
39 })
40 .collect()
41}
42
43#[derive(Debug)]
44struct Directive {
45 comment_start_byte: u32,
46 comment_end_byte: u32,
47 file_scope: bool,
48 rule_filters: Vec<String>,
49}
50
51fn directive_re() -> &'static Regex {
52 use std::sync::OnceLock;
53 static RE: OnceLock<Regex> = OnceLock::new();
54 RE.get_or_init(|| {
55 Regex::new(r"(?://|#|--)\s*code-moniker:\s*ignore(-file)?(?:\[([^\]]+)\])?").unwrap()
56 })
57}
58
59fn collect_directives(graph: &CodeGraph, source: &str) -> Vec<Directive> {
60 let mut out = collect_source_directives(source);
61 let mut seen: HashSet<(u32, u32, bool)> = out
62 .iter()
63 .map(|d| (d.comment_start_byte, d.comment_end_byte, d.file_scope))
64 .collect();
65 for d in graph.defs() {
66 if d.kind.as_ref() != KIND_COMMENT {
67 continue;
68 }
69 let Some((s, e)) = d.position else { continue };
70 let Some(text) = source.get(s as usize..e as usize) else {
71 continue;
72 };
73 let Some(caps) = directive_re().captures(text) else {
74 continue;
75 };
76 let directive = directive_from_captures(s, e, &caps);
77 if seen.insert((
78 directive.comment_start_byte,
79 directive.comment_end_byte,
80 directive.file_scope,
81 )) {
82 out.push(directive);
83 }
84 }
85 out
86}
87
88fn collect_source_directives(source: &str) -> Vec<Directive> {
89 let mut out = Vec::new();
90 let mut line_start = 0usize;
91 for line in source.split_inclusive('\n') {
92 let line_end = line_start + line.trim_end_matches(['\r', '\n']).len();
93 if let Some(caps) = directive_re().captures(line) {
94 out.push(directive_from_captures(
95 line_start as u32,
96 line_end as u32,
97 &caps,
98 ));
99 }
100 line_start += line.len();
101 }
102 out
103}
104
105fn directive_from_captures(start: u32, end: u32, caps: &Captures<'_>) -> Directive {
106 let file_scope = caps.get(1).is_some();
107 let rule_filters = caps
108 .get(2)
109 .map(|m| {
110 m.as_str()
111 .split(',')
112 .map(|s| s.trim().to_string())
113 .filter(|s| !s.is_empty())
114 .collect()
115 })
116 .unwrap_or_default();
117 Directive {
118 comment_start_byte: start,
119 comment_end_byte: end,
120 file_scope,
121 rule_filters,
122 }
123}
124
125fn target_lines_for(graph: &CodeGraph, source: &str, dir: &Directive) -> Option<(u32, u32)> {
126 let directive_lines = code_moniker_workspace::lines::line_range(
127 source,
128 dir.comment_start_byte,
129 dir.comment_end_byte,
130 );
131 let target_lines = next_def_after(graph, dir.comment_end_byte)
132 .and_then(|t| t.position)
133 .map(|(s, e)| code_moniker_workspace::lines::line_range(source, s, e));
134 Some(match target_lines {
135 Some(t) => (directive_lines.0.min(t.0), directive_lines.1.max(t.1)),
136 None => directive_lines,
137 })
138}
139
140fn next_def_after(graph: &CodeGraph, after_byte: u32) -> Option<&DefRecord> {
141 let mut best: Option<&DefRecord> = None;
142 for d in graph.defs() {
143 if d.kind.as_ref() == KIND_COMMENT {
144 continue;
145 }
146 let Some((s, _)) = d.position else { continue };
147 if s < after_byte {
148 continue;
149 }
150 match best {
151 None => best = Some(d),
152 Some(b) => {
153 let bs = b.position.map(|p| p.0).unwrap_or(u32::MAX);
154 if s < bs {
155 best = Some(d);
156 }
157 }
158 }
159 }
160 best
161}
162
163fn matches_id(dir: &Directive, rule_id: &str) -> bool {
164 if dir.rule_filters.is_empty() {
165 return true;
166 }
167 dir.rule_filters
168 .iter()
169 .any(|f| rule_id == f || rule_id.ends_with(&format!(".{f}")))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::check::config::Config;
176 use crate::check::evaluate;
177 use code_moniker_core::lang::Lang;
178 use code_moniker_workspace::extract;
179
180 fn run(source: &str, cfg: &Config) -> Vec<Violation> {
181 let graph = extract::extract(Lang::Ts, source, std::path::Path::new("test.ts"));
182 let violations = evaluate(&graph, source, Lang::Ts, cfg, "code+moniker://")
183 .expect("test config compiles");
184 apply(&graph, source, violations)
185 }
186
187 fn run_rust(source: &str, cfg: &Config) -> Vec<Violation> {
188 let graph = extract::extract(Lang::Rs, source, std::path::Path::new("test.rs"));
189 let violations = evaluate(&graph, source, Lang::Rs, cfg, "code+moniker://")
190 .expect("test config compiles");
191 apply(&graph, source, violations)
192 }
193
194 fn cfg(s: &str) -> Config {
195 toml::from_str(s).expect("test config must parse")
196 }
197
198 #[test]
199 fn ignore_without_filter_drops_next_def_violations() {
200 let cfg = cfg(r#"
201 [[ts.class.where]]
202 id = "name-pascal"
203 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
204 "#);
205 let source = "// code-moniker: ignore\nclass lower_bad {}\n";
206 assert!(run(source, &cfg).is_empty());
207 }
208
209 #[test]
210 fn ignore_with_specific_id_only_drops_matching_violations() {
211 let cfg = cfg(r#"
212 [[ts.class.where]]
213 id = "name-pascal"
214 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
215
216 [[ts.class.where]]
217 id = "max-lines"
218 expr = "lines <= 1"
219 "#);
220 let source = "// code-moniker: ignore[name-pascal]\nclass lower_bad {\n}\n";
221 let v = run(source, &cfg);
222 let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
223 assert!(!ids.contains(&"ts.class.name-pascal"), "{ids:?}");
224 assert!(
225 ids.contains(&"ts.class.max-lines"),
226 "max-lines should remain: {ids:?}"
227 );
228 }
229
230 #[test]
231 fn ignore_with_other_id_does_not_drop_violation() {
232 let cfg = cfg(r#"
233 [[ts.class.where]]
234 id = "name-pascal"
235 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
236 "#);
237 let source = "// code-moniker: ignore[max-lines]\nclass lower_bad {}\n";
238 let v = run(source, &cfg);
239 assert_eq!(v.len(), 1);
240 assert_eq!(v[0].rule_id, "ts.class.name-pascal");
241 }
242
243 #[test]
244 fn ignore_file_drops_violations_anywhere() {
245 let cfg = cfg(r#"
246 [[ts.class.where]]
247 id = "name-pascal"
248 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
249 "#);
250 let source = "// code-moniker: ignore-file\nclass lower_one {}\nclass another_lower {}\n";
251 assert!(run(source, &cfg).is_empty());
252 }
253
254 #[test]
255 fn ignore_file_with_filter_only_drops_listed_rules() {
256 let cfg = cfg(r#"
257 [[ts.class.where]]
258 id = "name-pascal"
259 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
260
261 [[ts.class.where]]
262 id = "max-lines"
263 expr = "lines <= 1"
264 "#);
265 let source = "// code-moniker: ignore-file[name-pascal]\nclass lower_one {\n}\n";
266 let v = run(source, &cfg);
267 let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
268 assert!(!ids.contains(&"ts.class.name-pascal"), "{ids:?}");
269 assert!(ids.contains(&"ts.class.max-lines"), "{ids:?}");
270 }
271
272 #[test]
273 fn ignore_only_applies_to_immediate_next_def() {
274 let cfg = cfg(r#"
275 [[ts.class.where]]
276 id = "name-pascal"
277 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
278 "#);
279 let source = "// code-moniker: ignore\nclass lower_one {}\nclass lower_two {}\n";
280 let v = run(source, &cfg);
281 let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
282 assert_eq!(v.len(), 1, "second class still flagged: {ids:?}");
283 }
284
285 #[test]
286 fn ignore_directives_dont_self_flag_as_prose() {
287 let cfg = cfg(r#"
288 [[ts.comment.where]]
289 id = "allow-only"
290 expr = '''text =~ ^\s*//\s*code-moniker:'''
291 "#);
292 let source = "// code-moniker: ignore\nclass Whatever {}\n";
293 assert!(run(source, &cfg).is_empty());
294 }
295
296 #[test]
297 fn ignore_suppresses_violation_on_comment_def_that_carries_directive() {
298 let cfg = cfg(r#"
299 [[ts.comment.where]]
300 id = "max-lines"
301 expr = "lines <= 2"
302 "#);
303 let source = "// code-moniker: ignore[max-lines]\n// a\n// b\n// c\n// d\nclass Foo {}\n";
308 let v = run(source, &cfg);
309 assert!(
310 v.is_empty(),
311 "directive must suppress the comment def it lives in: {v:?}"
312 );
313 }
314
315 #[test]
316 fn ignore_still_suppresses_next_non_comment_def() {
317 let cfg = cfg(r#"
318 [[ts.class.where]]
319 id = "name-pascal"
320 expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
321
322 [[ts.comment.where]]
323 id = "max-lines"
324 expr = "lines <= 2"
325 "#);
326 let source = "// code-moniker: ignore\nclass lower_bad {}\n";
329 assert!(run(source, &cfg).is_empty());
330 }
331
332 #[test]
333 fn ignore_after_attribute_suppresses_next_rust_def() {
334 let cfg = cfg(r#"
335 [[rust.fn.where]]
336 id = "name-snakecase"
337 expr = "name =~ ^[a-z_][a-z0-9_]*$"
338 "#);
339 let source = "#[allow(non_snake_case)]\n// code-moniker: ignore[rust.fn.name-snakecase]\nfn _PG_init() {}\n";
340 assert!(run_rust(source, &cfg).is_empty());
341 }
342}