Skip to main content

code_moniker_cli/check/
suppress.rs

1use 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
7// code-moniker: ignore[comment-max-lines]
8/// Strip violations suppressed by `// code-moniker: ignore` (or `#`/`--`)
9/// directives in comment-defs of the graph.
10///
11/// `ignore` (no `-file` suffix) suppresses violations on the comment def
12/// that carries the directive **and** the next def whose position starts
13/// at or after the comment's end byte. `ignore-file` applies to every
14/// violation in the file. The optional `[id1, id2, ...]` list scopes the
15/// suppression by rule-id suffix; without it, all rules are suppressed.
16pub 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		// The directive line is fused with the four `//` lines that follow
260		// (adjacent + same comment kind), so the resulting comment def has
261		// `lines = 5` and would trip `max-lines`. The directive must
262		// suppress its own def.
263		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		// A single-line directive + a bad class: the directive lives in its
283		// own comment def (cap fine), and still suppresses the class.
284		let source = "// code-moniker: ignore\nclass lower_bad {}\n";
285		assert!(run(source, &cfg).is_empty());
286	}
287}