big_code_analysis/suppression.rs
1//! In-source suppression markers for metric threshold checks.
2//!
3//! This module implements the comment-based suppression scanner
4//! described in issue #98. Two dialects coexist:
5//!
6//! - **Native markers** use the `bca:` namespace and the `suppress`
7//! verb, matching the codebase's internal "suppression" vocabulary
8//! (`SuppressionPolicy`, `FuncSpace::suppressed`, `--no-suppress`):
9//! - `bca: suppress` — suppress all metrics for the enclosing function.
10//! - `bca: suppress(cyclomatic, cognitive)` — suppress only the listed
11//! metrics for the enclosing function.
12//! - `bca: suppress-file` — suppress all metrics for the entire file.
13//! - `bca: suppress-file(halstead)` — suppress listed metrics file-wide.
14//! - **Lizard compatibility markers** are recognized verbatim so
15//! existing Lizard-instrumented codebases migrate without rewrites:
16//! - `#lizard forgives` ≡ `bca: suppress`.
17//! - `#lizard forgive global` ≡ `bca: suppress-file`.
18//!
19//! Markers are extracted from comment nodes during the AST walk in
20//! [`crate::spaces::metrics_with_options`] and attached to the matching
21//! [`crate::FuncSpace::suppressed`] field. Metric computation is
22//! unaffected — suppression is a *threshold-check* concern, not a
23//! *measurement* concern, so raw JSON / YAML output still reports every
24//! number.
25
26use std::collections::BTreeSet;
27use std::fmt;
28use std::str::FromStr;
29
30use serde::Serialize;
31
32/// Stable metric identifier set that suppression markers can name.
33///
34/// Names match the JSON field names emitted on [`crate::CodeMetrics`]
35/// (and on the per-metric `bca` threshold registry). Unknown
36/// identifiers in a `bca: suppress(...)` list produce a hard error so a
37/// typo cannot silently widen suppression scope to other metrics or be
38/// dropped on the floor.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum MetricKind {
42 /// Cognitive complexity.
43 Cognitive,
44 /// Cyclomatic complexity (both standard and modified variants).
45 Cyclomatic,
46 /// Halstead suite.
47 Halstead,
48 /// Lines-of-code suite (sloc, ploc, lloc, cloc, blank).
49 Loc,
50 /// Maintainability Index suite.
51 Mi,
52 /// Number of arguments.
53 Nargs,
54 /// Number of methods / functions.
55 Nom,
56 /// Number of public attributes.
57 Npa,
58 /// Number of public methods.
59 Npm,
60 /// ABC (assignments, branches, conditions) magnitude.
61 Abc,
62 /// Number of exit points.
63 Exit,
64 /// Weighted methods per class.
65 Wmc,
66}
67
68/// Whether downstream consumers (threshold checking, audit logging)
69/// should honor parsed suppression markers.
70///
71/// `Honor` is the default behaviour for `bca check` runs; `Ignore`
72/// powers the `--no-suppress` CLI flag so CI auditors can see the raw,
73/// un-silenced offender list without editing source files.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum SuppressionPolicy {
76 /// Skip violations whose metric is covered by an applicable marker.
77 Honor,
78 /// Emit every violation regardless of markers.
79 Ignore,
80}
81
82impl SuppressionPolicy {
83 /// Construct from a boolean `no_suppress` flag, as parsed from the
84 /// CLI. `true` means "ignore markers" (`--no-suppress` set);
85 /// `false` means "honor markers" (the default).
86 #[must_use]
87 pub const fn from_no_suppress(no_suppress: bool) -> Self {
88 if no_suppress {
89 Self::Ignore
90 } else {
91 Self::Honor
92 }
93 }
94}
95
96impl MetricKind {
97 /// Resolve a sub-metric threshold name (e.g. `cyclomatic.modified`,
98 /// `halstead.volume`, `loc.lloc`) to its parent [`MetricKind`].
99 ///
100 /// The threshold engine uses dotted forms to address individual
101 /// sub-metrics, but suppression markers only know about the
102 /// top-level metric family — silencing `halstead` silences all of
103 /// `halstead.volume`, `halstead.effort`, etc. This translation
104 /// happens here so the threshold-check loop can ask one question
105 /// ("does this scope cover this metric family?") instead of
106 /// special-casing each dotted name.
107 #[must_use]
108 pub fn for_threshold_name(name: &str) -> Option<Self> {
109 // Strip the dotted sub-metric suffix if present. `name` like
110 // `halstead.volume` becomes `halstead`; `nom` stays as-is.
111 let family = name.split_once('.').map_or(name, |(prefix, _)| prefix);
112 // `nexits` is the threshold-engine spelling for what the
113 // suppression vocabulary calls `exit` (matching the issue's
114 // explicit list). Alias it here rather than splitting one
115 // metric into two suppression identifiers.
116 let canonical = match family {
117 "nexits" => "exit",
118 "tokens" => return None,
119 other => other,
120 };
121 Self::from_str(canonical).ok()
122 }
123
124 /// Canonical string form. Round-trips through [`FromStr`].
125 #[must_use]
126 pub const fn as_str(self) -> &'static str {
127 match self {
128 Self::Cognitive => "cognitive",
129 Self::Cyclomatic => "cyclomatic",
130 Self::Halstead => "halstead",
131 Self::Loc => "loc",
132 Self::Mi => "mi",
133 Self::Nargs => "nargs",
134 Self::Nom => "nom",
135 Self::Npa => "npa",
136 Self::Npm => "npm",
137 Self::Abc => "abc",
138 Self::Exit => "exit",
139 Self::Wmc => "wmc",
140 }
141 }
142
143 /// Every [`MetricKind`] variant, in alphabetical order. Used to
144 /// render the "known metrics:" hint in error messages; the test
145 /// `metric_kind_all_is_alphabetical` locks the order so the hint
146 /// stays predictable across releases.
147 pub const ALL: &'static [Self] = &[
148 Self::Abc,
149 Self::Cognitive,
150 Self::Cyclomatic,
151 Self::Exit,
152 Self::Halstead,
153 Self::Loc,
154 Self::Mi,
155 Self::Nargs,
156 Self::Nom,
157 Self::Npa,
158 Self::Npm,
159 Self::Wmc,
160 ];
161}
162
163impl fmt::Display for MetricKind {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str(self.as_str())
166 }
167}
168
169impl FromStr for MetricKind {
170 type Err = ();
171 fn from_str(s: &str) -> Result<Self, Self::Err> {
172 Self::ALL
173 .iter()
174 .copied()
175 .find(|m| m.as_str() == s)
176 .ok_or(())
177 }
178}
179
180/// Which metrics a suppression marker covers.
181///
182/// `All` means the marker omits an explicit metric list and therefore
183/// silences every threshold for the enclosing scope. `Some` carries
184/// the explicit list parsed from `bca: suppress(a, b, c)`; an empty set
185/// means the marker effectively suppresses nothing (only possible via
186/// an empty `()` list, which is treated as a no-op rather than an
187/// error).
188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
189#[serde(rename_all = "snake_case", tag = "kind", content = "metrics")]
190pub enum SuppressionScope {
191 /// Suppress every metric.
192 All,
193 /// Suppress only the listed metrics.
194 Some(BTreeSet<MetricKind>),
195}
196
197impl Default for SuppressionScope {
198 /// The default scope suppresses nothing — empty `Some` so newly
199 /// constructed `FuncSpace`s carry "no suppressions" without having
200 /// to allocate.
201 fn default() -> Self {
202 Self::Some(BTreeSet::new())
203 }
204}
205
206impl SuppressionScope {
207 /// True when the scope suppresses every metric.
208 #[must_use]
209 pub fn is_all(&self) -> bool {
210 matches!(self, Self::All)
211 }
212
213 /// True when the scope suppresses nothing — used by serde to elide
214 /// the field from JSON output when no markers fired.
215 #[must_use]
216 pub fn is_empty(&self) -> bool {
217 matches!(self, Self::Some(s) if s.is_empty())
218 }
219
220 /// True when this scope suppresses `metric`.
221 #[must_use]
222 pub fn covers(&self, metric: MetricKind) -> bool {
223 match self {
224 Self::All => true,
225 Self::Some(s) => s.contains(&metric),
226 }
227 }
228
229 /// Merge `other` into `self`. `All` absorbs everything; otherwise
230 /// the two sets union. Used when multiple markers stack on the
231 /// same function or file.
232 pub(crate) fn merge(&mut self, other: &SuppressionScope) {
233 match (&mut *self, other) {
234 (Self::All, _) => {}
235 (slot, Self::All) => *slot = Self::All,
236 (Self::Some(a), Self::Some(b)) => a.extend(b.iter().copied()),
237 }
238 }
239}
240
241/// Whether a marker applies to the enclosing function or to the
242/// whole file.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub(crate) enum SuppressionKind {
245 /// Suppress thresholds for the function the comment lives in.
246 Function,
247 /// Suppress thresholds for the whole file.
248 File,
249}
250
251/// Which dialect surfaced this suppression — useful for the audit log
252/// so projects can migrate Lizard-style markers over time.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
254#[serde(rename_all = "snake_case")]
255pub(crate) enum SuppressionSource {
256 /// Native `bca:` marker.
257 Native,
258 /// Lizard compatibility marker.
259 Lizard,
260}
261
262/// A single suppression directive parsed from a comment.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub(crate) struct Suppression {
265 /// Function- vs file-scoped.
266 pub(crate) kind: SuppressionKind,
267 /// Which metrics the marker covers.
268 pub(crate) scope: SuppressionScope,
269 /// Native vs Lizard dialect.
270 pub(crate) source: SuppressionSource,
271}
272
273/// Error returned when a marker is recognized as a `bca:` directive but
274/// the body is malformed (unknown verb, malformed list, unknown metric
275/// identifier). Lizard-style markers never error: anything that does
276/// not match the exact `#lizard forgives` / `#lizard forgive global`
277/// shapes simply parses as "not a marker".
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub(crate) enum SuppressionError {
280 /// `bca:` directive used an unrecognized verb (anything other than
281 /// `suppress` / `suppress-file`).
282 UnknownVerb(String),
283 /// `bca: suppress(...)` listed an identifier that is not a known
284 /// metric name.
285 UnknownMetric(String),
286 /// `bca: suppress(...)` body could not be tokenized (e.g. unbalanced
287 /// parentheses, stray characters).
288 MalformedBody(String),
289}
290
291impl fmt::Display for SuppressionError {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 // Single-quote delimiters keep the rendered identifier readable
294 // without the `{:?}`-style escaping that would otherwise wrap
295 // user-supplied verb / metric tokens in literal backslashes.
296 match self {
297 Self::UnknownVerb(v) => write!(
298 f,
299 "unknown bca directive verb '{v}'; expected `suppress` or `suppress-file`"
300 ),
301 Self::UnknownMetric(m) => {
302 let known = MetricKind::ALL
303 .iter()
304 .map(|k| k.as_str())
305 .collect::<Vec<_>>()
306 .join(", ");
307 write!(
308 f,
309 "unknown metric '{m}' in bca suppression marker; known metrics: {known}"
310 )
311 }
312 Self::MalformedBody(body) => {
313 write!(f, "malformed bca suppression marker body '{body}'")
314 }
315 }
316 }
317}
318
319impl std::error::Error for SuppressionError {}
320
321/// Parse a single comment's text and try to extract a suppression
322/// directive. Returns:
323///
324/// - `Ok(None)` when the comment carries no marker (the common case).
325/// - `Ok(Some(s))` when a marker was successfully parsed.
326/// - `Err(e)` only for *native* markers whose body is malformed —
327/// Lizard-style markers never error.
328///
329/// The input is the raw comment text **including** the comment-syntax
330/// delimiters (e.g. `// bca: suppress`, `# bca: suppress`, `/* bca: suppress */`).
331/// The following leading delimiter characters are stripped before
332/// matching so per-language wrappers do not have to normalise:
333/// `/`, `*`, `!`, `#`, `;`, `-`, and ASCII whitespace. The `!` entry
334/// covers Rust inner doc comments (`//!`, `/*!`); the `;` and `-`
335/// entries cover Lisp / SQL / Lua line-comment shapes.
336pub(crate) fn parse_marker(comment_text: &str) -> Result<Option<Suppression>, SuppressionError> {
337 // Fast-bail: this function runs on every comment node. Most
338 // comments are license headers, doc comments, or TODO notes that
339 // contain neither sigil. `str::contains` is SIMD-accelerated and
340 // avoids the trim/strip chain below for the dominant case.
341 if !comment_text.contains("bca:") && !comment_text.contains("lizard") {
342 return Ok(None);
343 }
344
345 // Strip a `/*` opener and a `*/` closer if present so we don't
346 // confuse block-comment delimiters with marker prefixes.
347 let trimmed = strip_block_delims(comment_text.trim()).trim();
348
349 // Strip language-level comment openers *other than* `#`. We can't
350 // strip `#` here because Lizard's marker shape (`#lizard
351 // forgives`) needs the `#` to remain. In C++ `// #lizard ...`
352 // the `// ` must come off first so Lizard parsing sees `#lizard
353 // ...`. In Python `# #lizard ...` (the outer `#` is the language
354 // comment opener) tree-sitter delivers the raw `# #lizard ...`
355 // text — so the inner body still starts with `#`, which Lizard
356 // parsing wants. In both cases the no-`#` trim leaves the
357 // `#lizard` token intact.
358 // `!` is included so inner doc comments — `//! bca: suppress` and
359 // `/*! bca: suppress */` — strip down to the same body as their
360 // outer counterparts. Without this, the leading `!` would survive
361 // the strip and break the `bca:` prefix match.
362 let no_opener = trimmed
363 .trim_start_matches(|c: char| {
364 c == '/' || c == '*' || c == '!' || c == ';' || c == '-' || c.is_whitespace()
365 })
366 .trim_end_matches(|c: char| c == '*' || c == '/' || c.is_whitespace())
367 .trim();
368
369 // Python-style: tree-sitter delivers `# bca: suppress` with the
370 // leading `#` intact. Lizard expects `#lizard ...` — a literal
371 // `#` *followed by* `lizard`, no space. If the first `#` is the
372 // language's comment opener, strip exactly one `#` and any
373 // whitespace before retrying Lizard. The Python `# #lizard ...`
374 // shape is then also covered because two `#`s round-trip
375 // through one strip + one Lizard `#` prefix.
376 //
377 // Match `#l` only — Lizard's own scanner is case-sensitive
378 // (`parse_lizard` does `strip_prefix("lizard")`), so accepting
379 // `#L` here would just defer a failure to `parse_lizard`. Keeping
380 // the discriminator lowercase-only also matches the fast-bail
381 // above (`contains("lizard")`).
382 let lizard_candidate = if no_opener.starts_with("#l") {
383 // Already in `#lizard ...` shape after only block-delim
384 // stripping — typical for C++ where `// #lizard ...` has
385 // had `// ` removed above.
386 no_opener
387 } else if let Some(rest) = no_opener.strip_prefix('#') {
388 // Python/Bash style: `# #lizard ...` or `# bca: ...`. Drop
389 // the language comment opener; Lizard parsing only fires
390 // when what remains starts with another `#lizard`.
391 rest.trim_start()
392 } else {
393 no_opener
394 };
395
396 if let Some(s) = parse_lizard(lizard_candidate) {
397 return Ok(Some(s));
398 }
399
400 // For native parsing, strip the same `#` opener so `# bca: suppress`
401 // matches. The remaining body is then checked for the `bca:`
402 // prefix.
403 let body = no_opener
404 .trim_start_matches(|c: char| c == '#' || c.is_whitespace())
405 .trim();
406
407 parse_native(body)
408}
409
410fn strip_block_delims(s: &str) -> &str {
411 let s = s.strip_prefix("/*").unwrap_or(s);
412 s.strip_suffix("*/").unwrap_or(s)
413}
414
415fn parse_lizard(trimmed: &str) -> Option<Suppression> {
416 // `#lizard forgives` — function-scoped, all metrics.
417 // `#lizard forgive global` — file-scoped, all metrics.
418 //
419 // Lizard's own scanner tolerates a single space after `#` and
420 // around the verb, but is otherwise exact. We mirror that:
421 // canonicalize whitespace inside the marker, then match literals.
422 let s = trimmed.strip_prefix('#')?.trim_start();
423 let s = s.strip_prefix("lizard")?;
424 let rest = s.trim();
425
426 if rest == "forgives" {
427 return Some(Suppression {
428 kind: SuppressionKind::Function,
429 scope: SuppressionScope::All,
430 source: SuppressionSource::Lizard,
431 });
432 }
433 if rest == "forgive global" {
434 return Some(Suppression {
435 kind: SuppressionKind::File,
436 scope: SuppressionScope::All,
437 source: SuppressionSource::Lizard,
438 });
439 }
440 None
441}
442
443fn parse_native(body: &str) -> Result<Option<Suppression>, SuppressionError> {
444 // The native dialect is `bca:` followed by a verb (`suppress` or
445 // `suppress-file`), optionally followed by `(metric, metric, ...)`.
446 let Some(rest) = body.strip_prefix("bca:") else {
447 return Ok(None);
448 };
449 let rest = rest.trim_start();
450 if rest.is_empty() {
451 // A bare `bca:` with nothing after it isn't useful; treat as
452 // not-a-marker rather than an error so the user can write
453 // documentation that mentions the namespace without firing.
454 return Ok(None);
455 }
456
457 let malformed = || SuppressionError::MalformedBody(body.to_owned());
458
459 // Split into verb + parenthesised body. We accept whitespace
460 // between the verb and `(`. The verb is the longest prefix of
461 // ASCII letters and `-`.
462 let verb_end = rest
463 .find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
464 .unwrap_or(rest.len());
465 let (verb, after_verb) = rest.split_at(verb_end);
466 if verb.is_empty() {
467 return Err(malformed());
468 }
469
470 let kind = match verb {
471 "suppress" => SuppressionKind::Function,
472 "suppress-file" => SuppressionKind::File,
473 other => return Err(SuppressionError::UnknownVerb(other.to_owned())),
474 };
475
476 let after_verb = after_verb.trim_start();
477 let scope = if after_verb.is_empty() {
478 SuppressionScope::All
479 } else if let Some(rest) = after_verb.strip_prefix('(') {
480 let close = rest.find(')').ok_or_else(malformed)?;
481 let (inside, trailing) = rest.split_at(close);
482 // After the `)` only whitespace (and `*/` already trimmed by
483 // caller) is allowed. Anything else is a malformed marker:
484 // reject so `bca: suppress(loc) garbage` doesn't silently succeed.
485 if !trailing[1..].trim().is_empty() {
486 return Err(malformed());
487 }
488 parse_metric_list(inside)?
489 } else {
490 // Trailing text after the verb that isn't `(...)`: reject.
491 return Err(malformed());
492 };
493
494 Ok(Some(Suppression {
495 kind,
496 scope,
497 source: SuppressionSource::Native,
498 }))
499}
500
501fn parse_metric_list(inside: &str) -> Result<SuppressionScope, SuppressionError> {
502 let mut set = BTreeSet::new();
503 for token in inside.split(',') {
504 let name = token.trim();
505 if name.is_empty() {
506 // Empty `()` or trailing commas: skip. An empty list
507 // suppresses nothing — equivalent to the marker being
508 // absent. We accept rather than error so authors can
509 // comment out parts of a list during editing.
510 continue;
511 }
512 let metric = MetricKind::from_str(name)
513 .map_err(|()| SuppressionError::UnknownMetric(name.to_owned()))?;
514 set.insert(metric);
515 }
516 Ok(SuppressionScope::Some(set))
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn native_bare_suppress_covers_all_for_function() {
525 let s = parse_marker("// bca: suppress").unwrap().unwrap();
526 assert_eq!(s.kind, SuppressionKind::Function);
527 assert_eq!(s.source, SuppressionSource::Native);
528 assert!(matches!(s.scope, SuppressionScope::All));
529 }
530
531 #[test]
532 fn native_suppress_with_metric_list() {
533 let s = parse_marker("// bca: suppress(cyclomatic, cognitive)")
534 .unwrap()
535 .unwrap();
536 assert_eq!(s.kind, SuppressionKind::Function);
537 let SuppressionScope::Some(metrics) = s.scope else {
538 panic!("expected Some(...)");
539 };
540 assert!(metrics.contains(&MetricKind::Cyclomatic));
541 assert!(metrics.contains(&MetricKind::Cognitive));
542 assert_eq!(metrics.len(), 2);
543 }
544
545 #[test]
546 fn native_suppress_file_bare() {
547 let s = parse_marker("# bca: suppress-file").unwrap().unwrap();
548 assert_eq!(s.kind, SuppressionKind::File);
549 assert!(matches!(s.scope, SuppressionScope::All));
550 }
551
552 #[test]
553 fn native_suppress_file_with_metric_list() {
554 let s = parse_marker("/* bca: suppress-file(halstead, loc) */")
555 .unwrap()
556 .unwrap();
557 assert_eq!(s.kind, SuppressionKind::File);
558 let SuppressionScope::Some(metrics) = s.scope else {
559 panic!("expected Some(...)");
560 };
561 assert!(metrics.contains(&MetricKind::Halstead));
562 assert!(metrics.contains(&MetricKind::Loc));
563 }
564
565 #[test]
566 fn native_unknown_metric_errors() {
567 let err = parse_marker("// bca: suppress(no_such_metric)").unwrap_err();
568 assert!(matches!(err, SuppressionError::UnknownMetric(_)));
569 // The error must mention what was unknown so authors can
570 // diagnose typos without reading our source.
571 let rendered = err.to_string();
572 assert!(rendered.contains("no_such_metric"));
573 // And it must list the known metrics so a fix is one
574 // copy-paste away.
575 assert!(rendered.contains("cyclomatic"));
576 }
577
578 #[test]
579 fn native_unknown_verb_errors() {
580 let err = parse_marker("// bca: disable").unwrap_err();
581 assert!(matches!(err, SuppressionError::UnknownVerb(_)));
582 // The error message must guide the author toward the correct
583 // verbs without making them grep our source. Anchor each verb
584 // with its surrounding backticks so the bare `suppress` check
585 // can't be silently satisfied by the substring inside
586 // `suppress-file` — a future message that drops the bare verb
587 // and keeps only the compound one would otherwise pass this
588 // assertion.
589 let rendered = err.to_string();
590 assert!(
591 rendered.contains("`suppress`"),
592 "expected message to name the bare `suppress` verb; got: {rendered}"
593 );
594 assert!(
595 rendered.contains("`suppress-file`"),
596 "expected message to name the `suppress-file` verb; got: {rendered}"
597 );
598 }
599
600 /// Locks the hard rename in issue #263: the previous spelling
601 /// `// bca: allow` (and `// bca: allow-file`) must no longer be
602 /// recognized. They now fall through to `UnknownVerb`, the same
603 /// path as any other typo. A future revert that re-adds the old
604 /// verb to the match would silently re-enable old-style markers
605 /// in shipped source; this test catches that.
606 #[test]
607 fn legacy_allow_verb_is_unknown() {
608 let err = parse_marker("// bca: allow").unwrap_err();
609 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
610 let err = parse_marker("// bca: allow-file").unwrap_err();
611 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow-file"));
612 let err = parse_marker("// bca: allow(cyclomatic)").unwrap_err();
613 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
614 }
615
616 #[test]
617 fn native_malformed_body_errors() {
618 // Unbalanced paren.
619 assert!(matches!(
620 parse_marker("// bca: suppress(cyclomatic").unwrap_err(),
621 SuppressionError::MalformedBody(_)
622 ));
623 // Trailing garbage after the metric list.
624 assert!(matches!(
625 parse_marker("// bca: suppress(cyclomatic) junk").unwrap_err(),
626 SuppressionError::MalformedBody(_)
627 ));
628 // Verb followed by something other than `(...)`.
629 assert!(matches!(
630 parse_marker("// bca: suppress garbage").unwrap_err(),
631 SuppressionError::MalformedBody(_)
632 ));
633 }
634
635 #[test]
636 fn native_bare_colon_is_not_a_marker() {
637 // `bca:` with nothing after it is not a marker; we want to
638 // allow documentation comments to mention the namespace.
639 assert!(parse_marker("// bca:").unwrap().is_none());
640 }
641
642 #[test]
643 fn empty_metric_list_is_noop_not_error() {
644 let s = parse_marker("// bca: suppress()").unwrap().unwrap();
645 assert!(s.scope.is_empty());
646 assert!(!s.scope.covers(MetricKind::Cyclomatic));
647 }
648
649 #[test]
650 fn lizard_function_marker() {
651 let s = parse_marker("// #lizard forgives").unwrap().unwrap();
652 assert_eq!(s.kind, SuppressionKind::Function);
653 assert_eq!(s.source, SuppressionSource::Lizard);
654 assert!(matches!(s.scope, SuppressionScope::All));
655 }
656
657 #[test]
658 fn lizard_file_marker() {
659 let s = parse_marker("# #lizard forgive global").unwrap().unwrap();
660 assert_eq!(s.kind, SuppressionKind::File);
661 assert_eq!(s.source, SuppressionSource::Lizard);
662 }
663
664 #[test]
665 fn lizard_unknown_phrase_is_not_a_marker() {
666 // Per the issue's narrow compat surface: `#lizard skip` is not
667 // a recognized Lizard directive, so we treat it as no marker
668 // rather than erroring or silently suppressing.
669 assert!(parse_marker("// #lizard skip").unwrap().is_none());
670 }
671
672 #[test]
673 fn plain_comment_is_not_a_marker() {
674 assert!(parse_marker("// just a comment").unwrap().is_none());
675 assert!(parse_marker("/* TODO: fix later */").unwrap().is_none());
676 }
677
678 /// Locks the fast-bail contract in `parse_marker`: comments that
679 /// contain neither `bca:` nor `lizard` must short-circuit to
680 /// `Ok(None)`. A future change broadening the substring check
681 /// (case-insensitive, etc.) would silently shift parsing semantics
682 /// for comments that mention `Bca:` or `Lizard` in prose; this
683 /// test catches that.
684 #[test]
685 fn fast_bail_skips_sigil_free_comments() {
686 // Long, sigil-free comments that should never trigger.
687 assert!(
688 parse_marker("// Copyright (c) 2026 Some Corp.")
689 .unwrap()
690 .is_none()
691 );
692 assert!(
693 parse_marker("/* SPDX-License-Identifier: MIT */")
694 .unwrap()
695 .is_none()
696 );
697 // Substring-mention-but-not-a-marker: contains "lizard" in
698 // prose but is not a Lizard directive. Slow path must still
699 // return Ok(None).
700 assert!(
701 parse_marker("// authors: jane lizard, john doe")
702 .unwrap()
703 .is_none()
704 );
705 }
706
707 /// Locks the case sensitivity of both dialects: `Bca:` and
708 /// `#Lizard` must NOT be recognized. Both the fast-bail and the
709 /// underlying parsers are lowercase-only by design; this test
710 /// pins that contract.
711 #[test]
712 fn marker_grammar_is_case_sensitive() {
713 // Uppercase B in `Bca:` is not a native marker.
714 assert!(parse_marker("// Bca: suppress").unwrap().is_none());
715 assert!(parse_marker("/* BCA: suppress */").unwrap().is_none());
716 // Uppercase L in `#Lizard` is not a Lizard marker. The
717 // fast-bail rejects it (no lowercase "lizard" substring) and
718 // the slow path would also reject it via `strip_prefix("lizard")`.
719 assert!(parse_marker("# #Lizard forgives").unwrap().is_none());
720 assert!(parse_marker("// #Lizard forgives").unwrap().is_none());
721 }
722
723 #[test]
724 fn metric_kind_round_trips() {
725 for &m in MetricKind::ALL {
726 assert_eq!(MetricKind::from_str(m.as_str()), Ok(m));
727 }
728 }
729
730 #[test]
731 fn metric_kind_all_is_alphabetical() {
732 assert!(
733 MetricKind::ALL.is_sorted_by_key(|m| m.as_str()),
734 "MetricKind::ALL must stay sorted so the error-hint ordering is stable; got {:?}",
735 MetricKind::ALL
736 .iter()
737 .map(|m| m.as_str())
738 .collect::<Vec<_>>(),
739 );
740 }
741
742 #[test]
743 fn scope_merge_all_absorbs() {
744 let mut a = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
745 a.merge(&SuppressionScope::All);
746 assert!(a.is_all());
747
748 let mut b = SuppressionScope::All;
749 b.merge(&SuppressionScope::Some(BTreeSet::from([MetricKind::Loc])));
750 assert!(b.is_all());
751 }
752
753 #[test]
754 fn scope_merge_some_unions() {
755 let mut a = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
756 a.merge(&SuppressionScope::Some(BTreeSet::from([
757 MetricKind::Cognitive,
758 ])));
759 assert!(a.covers(MetricKind::Loc));
760 assert!(a.covers(MetricKind::Cognitive));
761 assert!(!a.covers(MetricKind::Cyclomatic));
762 }
763
764 #[test]
765 fn scope_covers_respects_all_vs_some() {
766 assert!(SuppressionScope::All.covers(MetricKind::Cyclomatic));
767 let some = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
768 assert!(some.covers(MetricKind::Loc));
769 assert!(!some.covers(MetricKind::Cyclomatic));
770 }
771
772 #[test]
773 fn for_threshold_name_maps_dotted_subnames_to_families() {
774 // Cyclomatic.modified and cyclomatic both fall under
775 // MetricKind::Cyclomatic — silencing `cyclomatic` covers the
776 // modified variant too. Same for halstead.* and loc.*.
777 assert_eq!(
778 MetricKind::for_threshold_name("cyclomatic"),
779 Some(MetricKind::Cyclomatic)
780 );
781 assert_eq!(
782 MetricKind::for_threshold_name("cyclomatic.modified"),
783 Some(MetricKind::Cyclomatic)
784 );
785 assert_eq!(
786 MetricKind::for_threshold_name("halstead.volume"),
787 Some(MetricKind::Halstead)
788 );
789 assert_eq!(
790 MetricKind::for_threshold_name("loc.lloc"),
791 Some(MetricKind::Loc)
792 );
793 }
794
795 #[test]
796 fn for_threshold_name_aliases_nexits_to_exit() {
797 // The threshold engine surfaces this metric as `nexits`; the
798 // suppression vocabulary uses `exit`. The translation must
799 // happen here so `bca: suppress(exit)` silences a `nexits`
800 // threshold violation as authors expect.
801 assert_eq!(
802 MetricKind::for_threshold_name("nexits"),
803 Some(MetricKind::Exit)
804 );
805 }
806
807 #[test]
808 fn for_threshold_name_returns_none_for_unknown() {
809 // `tokens` is in the threshold registry but explicitly absent
810 // from the suppression metric set (the issue's list does not
811 // include it). Treat as "no metric family" so a marker can't
812 // silence the threshold; this is conservative — the issue
813 // says unknown identifiers must error, but here we're going
814 // the other direction (threshold-name → MetricKind) so the
815 // safe choice is "no mapping, no silencing".
816 assert_eq!(MetricKind::for_threshold_name("tokens"), None);
817 assert_eq!(MetricKind::for_threshold_name("no_such_metric"), None);
818 }
819
820 #[test]
821 fn default_scope_is_empty() {
822 let d = SuppressionScope::default();
823 assert!(d.is_empty());
824 assert!(!d.is_all());
825 }
826
827 #[test]
828 fn inner_doc_comments_recognized() {
829 // Rust inner doc comments (`//!`, `/*!`) are the same shape as
830 // their outer counterparts (`///`, `/**`) modulo the `!` byte.
831 // Without `!` in the leading-strip set the marker prefix `bca:`
832 // would not match. Both line- and block-comment variants must
833 // round-trip the same way.
834 let line = parse_marker("//! bca: suppress").unwrap().unwrap();
835 assert_eq!(line.kind, SuppressionKind::Function);
836 assert!(matches!(line.scope, SuppressionScope::All));
837
838 let block = parse_marker("/*! bca: suppress */").unwrap().unwrap();
839 assert_eq!(block.kind, SuppressionKind::Function);
840 assert!(matches!(block.scope, SuppressionScope::All));
841 }
842}