Skip to main content

code_moniker_check/check/
suppress.rs

1use 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
9// code-moniker: ignore[comment-max-lines]
10/// Strip violations suppressed by `// code-moniker: ignore` (or `#`/`--`)
11/// directives in comment-defs of the graph.
12///
13/// `ignore` (no `-file` suffix) suppresses violations on the comment def
14/// that carries the directive **and** the next def whose position starts
15/// at or after the comment's end byte. `ignore-file` applies to every
16/// violation in the file. The optional `[id1, id2, ...]` list scopes the
17/// suppression by rule-id suffix; without it, all rules are suppressed.
18pub 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		// The directive line is fused with the four `//` lines that follow
304		// (adjacent + same comment kind), so the resulting comment def has
305		// `lines = 5` and would trip `max-lines`. The directive must
306		// suppress its own def.
307		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		// A single-line directive + a bad class: the directive lives in its
327		// own comment def (cap fine), and still suppresses the class.
328		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}