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