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/// Strip violations suppressed by `// code-moniker: ignore` (or `#`/`--`)
8/// directives in comment-defs of the graph.
9///
10/// `ignore` (no `-file` suffix) suppresses violations on the next def whose
11/// position starts at or after the comment's end byte. `ignore-file` applies
12/// to every violation in the file. The optional `[id1, id2, ...]` list scopes
13/// the suppression by rule-id suffix; without it, all rules are suppressed.
14pub fn apply(graph: &CodeGraph, source: &str, violations: Vec<Violation>) -> Vec<Violation> {
15	let directives = collect_directives(graph, source);
16	if directives.is_empty() {
17		return violations;
18	}
19
20	let file_scope: Vec<&Directive> = directives.iter().filter(|d| d.file_scope).collect();
21	let line_scope: Vec<(&Directive, Option<(u32, u32)>)> = directives
22		.iter()
23		.filter(|d| !d.file_scope)
24		.map(|d| (d, target_lines_for(graph, source, d)))
25		.collect();
26
27	violations
28		.into_iter()
29		.filter(|v| {
30			!file_scope.iter().any(|d| matches_id(d, &v.rule_id))
31				&& !line_scope.iter().any(|(d, target)| {
32					matches_id(d, &v.rule_id)
33						&& target.is_some_and(|(s, e)| v.lines.0 >= s && v.lines.0 <= e)
34				})
35		})
36		.collect()
37}
38
39#[derive(Debug)]
40struct Directive {
41	comment_end_byte: u32,
42	file_scope: bool,
43	rule_filters: Vec<String>,
44}
45
46fn directive_re() -> &'static Regex {
47	use std::sync::OnceLock;
48	static RE: OnceLock<Regex> = OnceLock::new();
49	RE.get_or_init(|| {
50		Regex::new(r"(?://|#|--)\s*code-moniker:\s*ignore(-file)?(?:\[([^\]]+)\])?").unwrap()
51	})
52}
53
54fn collect_directives(graph: &CodeGraph, source: &str) -> Vec<Directive> {
55	let mut out = Vec::new();
56	for d in graph.defs() {
57		if d.kind.as_slice() != KIND_COMMENT {
58			continue;
59		}
60		let Some((s, e)) = d.position else { continue };
61		let Some(text) = source.get(s as usize..e as usize) else {
62			continue;
63		};
64		let Some(caps) = directive_re().captures(text) else {
65			continue;
66		};
67		let file_scope = caps.get(1).is_some();
68		let rule_filters = caps
69			.get(2)
70			.map(|m| {
71				m.as_str()
72					.split(',')
73					.map(|s| s.trim().to_string())
74					.filter(|s| !s.is_empty())
75					.collect()
76			})
77			.unwrap_or_default();
78		out.push(Directive {
79			comment_end_byte: e,
80			file_scope,
81			rule_filters,
82		});
83	}
84	out
85}
86
87fn target_lines_for(graph: &CodeGraph, source: &str, dir: &Directive) -> Option<(u32, u32)> {
88	let target = next_def_after(graph, dir.comment_end_byte)?;
89	let (s, e) = target.position?;
90	Some(crate::lines::line_range(source, s, e))
91}
92
93fn next_def_after(graph: &CodeGraph, after_byte: u32) -> Option<&DefRecord> {
94	let mut best: Option<&DefRecord> = None;
95	for d in graph.defs() {
96		if d.kind.as_slice() == KIND_COMMENT {
97			continue;
98		}
99		let Some((s, _)) = d.position else { continue };
100		if s < after_byte {
101			continue;
102		}
103		match best {
104			None => best = Some(d),
105			Some(b) => {
106				let bs = b.position.map(|p| p.0).unwrap_or(u32::MAX);
107				if s < bs {
108					best = Some(d);
109				}
110			}
111		}
112	}
113	best
114}
115
116fn matches_id(dir: &Directive, rule_id: &str) -> bool {
117	if dir.rule_filters.is_empty() {
118		return true;
119	}
120	dir.rule_filters
121		.iter()
122		.any(|f| rule_id == f || rule_id.ends_with(&format!(".{f}")))
123}
124
125#[cfg(test)]
126mod tests {
127	use super::*;
128	use crate::check::config::Config;
129	use crate::check::evaluate;
130	use crate::extract;
131	use code_moniker_core::lang::Lang;
132
133	fn run(source: &str, cfg: &Config) -> Vec<Violation> {
134		let graph = extract::extract(Lang::Ts, source, std::path::Path::new("test.ts"));
135		let violations = evaluate(&graph, source, Lang::Ts, cfg, "code+moniker://")
136			.expect("test config compiles");
137		apply(&graph, source, violations)
138	}
139
140	fn cfg(s: &str) -> Config {
141		toml::from_str(s).expect("test config must parse")
142	}
143
144	#[test]
145	fn ignore_without_filter_drops_next_def_violations() {
146		let cfg = cfg(r#"
147			[[ts.class.where]]
148			id   = "name-pascal"
149			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
150			"#);
151		let source = "// code-moniker: ignore\nclass lower_bad {}\n";
152		assert!(run(source, &cfg).is_empty());
153	}
154
155	#[test]
156	fn ignore_with_specific_id_only_drops_matching_violations() {
157		let cfg = cfg(r#"
158			[[ts.class.where]]
159			id   = "name-pascal"
160			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
161
162			[[ts.class.where]]
163			id   = "max-lines"
164			expr = "lines <= 1"
165			"#);
166		let source = "// code-moniker: ignore[name-pascal]\nclass lower_bad {\n}\n";
167		let v = run(source, &cfg);
168		let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
169		assert!(!ids.contains(&"ts.class.name-pascal"), "{ids:?}");
170		assert!(
171			ids.contains(&"ts.class.max-lines"),
172			"max-lines should remain: {ids:?}"
173		);
174	}
175
176	#[test]
177	fn ignore_with_other_id_does_not_drop_violation() {
178		let cfg = cfg(r#"
179			[[ts.class.where]]
180			id   = "name-pascal"
181			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
182			"#);
183		let source = "// code-moniker: ignore[max-lines]\nclass lower_bad {}\n";
184		let v = run(source, &cfg);
185		assert_eq!(v.len(), 1);
186		assert_eq!(v[0].rule_id, "ts.class.name-pascal");
187	}
188
189	#[test]
190	fn ignore_file_drops_violations_anywhere() {
191		let cfg = cfg(r#"
192			[[ts.class.where]]
193			id   = "name-pascal"
194			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
195			"#);
196		let source = "// code-moniker: ignore-file\nclass lower_one {}\nclass another_lower {}\n";
197		assert!(run(source, &cfg).is_empty());
198	}
199
200	#[test]
201	fn ignore_file_with_filter_only_drops_listed_rules() {
202		let cfg = cfg(r#"
203			[[ts.class.where]]
204			id   = "name-pascal"
205			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
206
207			[[ts.class.where]]
208			id   = "max-lines"
209			expr = "lines <= 1"
210			"#);
211		let source = "// code-moniker: ignore-file[name-pascal]\nclass lower_one {\n}\n";
212		let v = run(source, &cfg);
213		let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
214		assert!(!ids.contains(&"ts.class.name-pascal"), "{ids:?}");
215		assert!(ids.contains(&"ts.class.max-lines"), "{ids:?}");
216	}
217
218	#[test]
219	fn ignore_only_applies_to_immediate_next_def() {
220		let cfg = cfg(r#"
221			[[ts.class.where]]
222			id   = "name-pascal"
223			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
224			"#);
225		let source = "// code-moniker: ignore\nclass lower_one {}\nclass lower_two {}\n";
226		let v = run(source, &cfg);
227		let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
228		assert_eq!(v.len(), 1, "second class still flagged: {ids:?}");
229	}
230
231	#[test]
232	fn ignore_directives_dont_self_flag_as_prose() {
233		let cfg = cfg(r#"
234			[[ts.comment.where]]
235			id   = "allow-only"
236			expr = '''text =~ ^\s*//\s*code-moniker:'''
237			"#);
238		let source = "// code-moniker: ignore\nclass Whatever {}\n";
239		assert!(run(source, &cfg).is_empty());
240	}
241}