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//!
15//! A marker that names a metric list may carry a trailing rationale on
16//! the same line (`bca: suppress(nargs) — threaded context, not a
17//! god-function`), with no separator required: the parentheses are
18//! the positive signal that distinguish a marker from prose. A *bare*
19//! verb takes no trailing text at all — with nothing to anchor the
20//! intent, no separator distinguishes a rationale from a sentence
21//! *about* the marker, and reading the latter as a marker would
22//! silence every metric in the enclosing function.
23//! - **Lizard compatibility markers** are recognized verbatim so
24//! existing Lizard-instrumented codebases migrate without rewrites:
25//! - `#lizard forgives` ≡ `bca: suppress`.
26//! - `#lizard forgive global` ≡ `bca: suppress-file`.
27//!
28//! Markers are extracted from comment nodes during the AST walk in
29//! [`crate::analyze`] / [`crate::Ast::metrics`] and attached to the
30//! matching [`crate::FuncSpace::suppressed`] field. Metric computation is
31//! unaffected — suppression is a *threshold-check* concern, not a
32//! *measurement* concern, so raw JSON / YAML output still reports every
33//! number.
34
35use std::collections::BTreeSet;
36use std::fmt;
37use std::sync::OnceLock;
38
39use serde::{Deserialize, Serialize};
40
41use crate::checker::Checker;
42use crate::getter::Getter;
43use crate::metric_set::Metric;
44use crate::node::{Ancestors, Node};
45use crate::traits::ParserTrait;
46
47/// Resolve a sub-metric threshold name (e.g. `cyclomatic.modified`,
48/// `halstead.volume`, `loc.lloc`) to its parent [`Metric`].
49///
50/// The threshold engine uses dotted forms to address individual
51/// sub-metrics, but suppression markers only know about the top-level
52/// metric family — silencing `halstead` silences all of
53/// `halstead.volume`, `halstead.effort`, etc. This translation happens
54/// here so the threshold-check loop can ask one question ("does this
55/// scope cover this metric family?") instead of special-casing each
56/// dotted name.
57///
58/// Returns `None` for `tokens`: it has no configurable threshold and is
59/// deliberately absent from the suppressible vocabulary
60/// ([`Metric::suppressible`]), so a marker can never silence it.
61#[must_use]
62pub fn threshold_metric_for_name(name: &str) -> Option<Metric> {
63 // Strip the dotted sub-metric suffix if present. `name` like
64 // `halstead.volume` becomes `halstead`; `nom` stays as-is.
65 let family = name.split_once('.').map_or(name, |(prefix, _)| prefix);
66 // `tokens` is in the threshold registry but is not suppressible, so
67 // it maps to no metric family. Every other name parses via the
68 // canonical `Metric::from_str` — `nexits` is the spelling on both
69 // sides now, so no alias bridge is needed (the pre-unification
70 // `nexits -> exit` mapping retired with `MetricKind` in #555).
71 if family == "tokens" {
72 return None;
73 }
74 family.parse().ok()
75}
76
77/// Whether downstream consumers (threshold checking, audit logging)
78/// should honor parsed suppression markers.
79///
80/// `Honor` is the default behaviour for `bca check` runs; `Ignore`
81/// powers the `--no-suppress` CLI flag so CI auditors can see the raw,
82/// un-silenced offender list without editing source files.
83// Deliberately exhaustive: a total binary toggle (honor markers vs
84// ignore them). There is no third state to add, so `#[non_exhaustive]`
85// would only force callers into a pointless wildcard arm.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SuppressionPolicy {
88 /// Skip violations whose metric is covered by an applicable marker.
89 Honor,
90 /// Emit every violation regardless of markers.
91 Ignore,
92}
93
94impl SuppressionPolicy {
95 /// Construct from a boolean `no_suppress` flag, as parsed from the
96 /// CLI. `true` means "ignore markers" (`--no-suppress` set);
97 /// `false` means "honor markers" (the default).
98 #[must_use]
99 pub const fn from_no_suppress(no_suppress: bool) -> Self {
100 if no_suppress {
101 Self::Ignore
102 } else {
103 Self::Honor
104 }
105 }
106}
107
108/// Which metrics a suppression marker covers.
109///
110/// `All` means the marker omits an explicit metric list and therefore
111/// silences every threshold for the enclosing scope. `Some` carries
112/// the explicit list parsed from `bca: suppress(a, b, c)`; an empty set
113/// means the marker effectively suppresses nothing (only possible via
114/// an empty `()` list, which is treated as a no-op rather than an
115/// error).
116// Deliberately exhaustive: a total model of "everything (`All`) vs an
117// explicit set (`Some`)". Any new coverage shape is expressible as a
118// `Some(set)` rather than a new variant, so the two cases are closed.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case", tag = "kind", content = "metrics")]
121pub enum SuppressionScope {
122 /// Suppress every metric.
123 All,
124 /// Suppress only the listed metrics.
125 Some(BTreeSet<Metric>),
126}
127
128impl Default for SuppressionScope {
129 /// The default scope suppresses nothing — empty `Some` so newly
130 /// constructed `FuncSpace`s carry "no suppressions" without having
131 /// to allocate.
132 fn default() -> Self {
133 Self::Some(BTreeSet::new())
134 }
135}
136
137impl SuppressionScope {
138 /// True when the scope suppresses every metric.
139 #[must_use]
140 pub fn is_all(&self) -> bool {
141 matches!(self, Self::All)
142 }
143
144 /// True when the scope suppresses nothing — used by serde to elide
145 /// the field from JSON output when no markers fired.
146 #[must_use]
147 pub fn is_empty(&self) -> bool {
148 matches!(self, Self::Some(s) if s.is_empty())
149 }
150
151 /// True when this scope suppresses `metric`.
152 #[must_use]
153 pub fn covers(&self, metric: Metric) -> bool {
154 match self {
155 Self::All => true,
156 Self::Some(s) => s.contains(&metric),
157 }
158 }
159
160 /// Merge `other` into `self`. `All` absorbs everything; otherwise
161 /// the two sets union. Used when multiple markers stack on the
162 /// same function or file, and by report consumers to fold a file's
163 /// `suppress-file` scope into each function's own scope (issue #501).
164 pub fn merge(&mut self, other: &SuppressionScope) {
165 match (&mut *self, other) {
166 (Self::All, _) => {}
167 (slot, Self::All) => *slot = Self::All,
168 (Self::Some(a), Self::Some(b)) => a.extend(b.iter().copied()),
169 }
170 }
171}
172
173/// Whether a marker applies to the enclosing function or to the
174/// whole file.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub(crate) enum SuppressionKind {
177 /// Suppress thresholds for the function the comment lives in.
178 Function,
179 /// Suppress thresholds for the whole file.
180 File,
181}
182
183/// Which dialect surfaced this suppression — useful for the audit log
184/// so projects can migrate Lizard-style markers over time.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub(crate) enum SuppressionSource {
188 /// Native `bca:` marker.
189 Native,
190 /// Lizard compatibility marker.
191 Lizard,
192}
193
194/// A single suppression directive parsed from a comment.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub(crate) struct Suppression {
197 /// Function- vs file-scoped.
198 pub(crate) kind: SuppressionKind,
199 /// Which metrics the marker covers.
200 pub(crate) scope: SuppressionScope,
201 /// Native vs Lizard dialect.
202 pub(crate) source: SuppressionSource,
203}
204
205/// What scanning one comment for a suppression marker produced.
206///
207/// The two fields are independent, and that is the point (issue #1168):
208/// a marker can be *partly* usable — `bca: suppress(cognitive, exit)`
209/// silences `cognitive` and reports `exit` — where the previous
210/// `Result` shape forced every flaw to void the whole marker. The
211/// governing rule is that a comment recognisable as a `bca: suppress`
212/// marker never silently does nothing: it either suppresses what it
213/// names or produces a diagnostic, and often both.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub(crate) struct MarkerScan {
216 /// The directive to apply, when the comment carried a usable one.
217 /// `None` for an ordinary comment and for a body that could not be
218 /// parsed at all.
219 pub(crate) suppression: Option<Suppression>,
220 /// Everything wrong with the marker, in source order. Empty for the
221 /// dominant case. Callers on the threshold path render these as
222 /// `warning:` lines; the read-only audit walk ignores them.
223 pub(crate) diagnostics: Vec<SuppressionError>,
224}
225
226impl MarkerScan {
227 /// The comment carries no marker — the common case.
228 fn not_a_marker() -> Self {
229 Self {
230 suppression: None,
231 diagnostics: Vec::new(),
232 }
233 }
234
235 /// The comment opens a `bca:` directive that could not be parsed
236 /// into one at all. Nothing is suppressed and `error` is reported.
237 fn rejected(error: SuppressionError) -> Self {
238 Self {
239 suppression: None,
240 diagnostics: vec![error],
241 }
242 }
243
244 /// A marker with nothing to complain about.
245 fn directive(suppression: Suppression) -> Self {
246 Self {
247 suppression: Some(suppression),
248 diagnostics: Vec::new(),
249 }
250 }
251
252 /// A usable marker that still drew complaints — the partly-usable
253 /// case issue #1168 exists for. `suppression` covers what parsed;
254 /// `diagnostics` names what did not.
255 fn partial(suppression: Suppression, diagnostics: Vec<SuppressionError>) -> Self {
256 Self {
257 suppression: Some(suppression),
258 diagnostics,
259 }
260 }
261}
262
263/// A flaw in a marker that is recognizably a `bca:` directive: an
264/// unknown verb, an unparseable body, or a metric name that cannot be
265/// honoured. Lizard-style markers never produce one: anything that does
266/// not match the exact `#lizard forgives` / `#lizard forgive global`
267/// shapes simply parses as "not a marker".
268///
269/// The first two void the marker; a bad metric name only drops that one
270/// name from the list (issue #1168).
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub(crate) enum SuppressionError {
273 /// `bca:` directive used an unrecognized verb (anything other than
274 /// `suppress` / `suppress-file`).
275 UnknownVerb(String),
276 /// `bca: suppress(...)` listed an identifier that is not a known
277 /// metric name. Reported and skipped; the recognized names beside it
278 /// still suppress.
279 UnknownMetric(String),
280 /// `bca: suppress(...)` named a real metric that has no configurable
281 /// threshold and therefore cannot be suppressed (currently only
282 /// `tokens`). Distinct from [`Self::UnknownMetric`] so the author
283 /// learns the name parsed but is simply not silenceable.
284 NonSuppressibleMetric(String),
285 /// `bca: suppress(...)` body could not be tokenized (e.g. an
286 /// unbalanced parenthesis, or a bare verb followed by any trailing
287 /// text).
288 MalformedBody(String),
289 /// More distinct unusable names than [`MAX_MARKER_DIAGNOSTICS`], so
290 /// the tail was elided. Carries the number dropped, because a silent
291 /// truncation would understate how wrong the marker is.
292 ElidedDiagnostics(usize),
293}
294
295/// Cap on the diagnostics one suppression marker may emit.
296///
297/// Names are deduplicated before the cap applies, so reaching it takes a
298/// marker with eight *distinct* unusable names — well past any real
299/// typo, and into the territory of `bca: suppress(a,b,c,…)` in a
300/// third-party tree. Each diagnostic renders the full suppressible-metric
301/// hint (~130 characters), so without a cap a large enough comment turns
302/// one marker into megabytes of stderr.
303const MAX_MARKER_DIAGNOSTICS: usize = 8;
304
305/// The suppressible-metric vocabulary, rendered once.
306///
307/// Built lazily and cached: [`SuppressionError::UnknownMetric`]'s
308/// `Display` is invoked once per diagnostic, and rebuilding, allocating
309/// and sorting this list on every one made a marker's cost quadratic in
310/// its own length.
311fn suppressible_metric_hint() -> &'static str {
312 static HINT: OnceLock<String> = OnceLock::new();
313 HINT.get_or_init(|| {
314 // `Metric::suppressible()` is the single source of truth for the
315 // suppressible vocabulary — it already excludes the
316 // non-suppressible `tokens` — so the hint is never re-derived
317 // from `Metric::NAMES` with a hardcoded filter. It iterates
318 // declaration order; we sort so the hint stays alphabetised and
319 // thus stable across releases.
320 let mut names: Vec<String> = Metric::suppressible()
321 .map(|metric| metric.to_string())
322 .collect();
323 names.sort_unstable();
324 names.join(", ")
325 })
326}
327
328impl fmt::Display for SuppressionError {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 // Single-quote delimiters keep the rendered identifier readable
331 // without the `{:?}`-style escaping that would otherwise wrap
332 // user-supplied verb / metric tokens in literal backslashes.
333 match self {
334 Self::UnknownVerb(v) => write!(
335 f,
336 "unknown bca directive verb '{v}'; expected `suppress` or `suppress-file`"
337 ),
338 Self::UnknownMetric(m) => {
339 write!(
340 f,
341 "unknown metric '{m}' in bca suppression marker; known metrics: {}",
342 suppressible_metric_hint()
343 )
344 }
345 Self::NonSuppressibleMetric(m) => {
346 write!(f, "metric '{m}' has no threshold and cannot be suppressed")
347 }
348 Self::ElidedDiagnostics(n) => write!(
349 f,
350 "… and {n} more unusable metric name(s) in this bca suppression marker"
351 ),
352 Self::MalformedBody(body) => {
353 // This warning is the only thing standing between the
354 // author and a marker that silently does nothing, so it
355 // names both the accepted shapes and the two ways out of
356 // the commonest mistake — a reason written after a bare
357 // verb, which no separator can distinguish from prose
358 // about the marker.
359 write!(
360 f,
361 "malformed bca suppression marker body '{body}'; expected \
362 `bca: suppress` / `bca: suppress-file` with nothing after \
363 the verb, or `bca: suppress(<metrics>)`, which may carry a \
364 rationale (`bca: suppress(cognitive, cyclomatic) — \
365 reason`); to keep a reason here, name the metrics or move \
366 the reason to the line above"
367 )
368 }
369 }
370 }
371}
372
373impl std::error::Error for SuppressionError {}
374
375/// Parse a single comment's text and try to extract a suppression
376/// directive, returning both the directive (if any) and every complaint
377/// about it — see [`MarkerScan`].
378///
379/// A comment that is not a marker yields an empty scan; a *native*
380/// marker that is recognizable but flawed yields at least one
381/// diagnostic. Lizard-style markers never produce diagnostics: anything
382/// off-shape simply is not a marker.
383///
384/// The input is the raw comment text **including** the comment-syntax
385/// delimiters (e.g. `// bca: suppress`, `# bca: suppress`, `/* bca: suppress */`).
386/// The following leading delimiter characters are stripped before
387/// matching so per-language wrappers do not have to normalise:
388/// `/`, `*`, `!`, `#`, `;`, `-`, and ASCII whitespace. The `!` entry
389/// covers Rust inner doc comments (`//!`, `/*!`); the `;` and `-`
390/// entries cover Lisp / SQL / Lua line-comment shapes.
391pub(crate) fn parse_marker(comment_text: &str) -> MarkerScan {
392 // Fast-bail: this function runs on every comment node. Most
393 // comments are license headers, doc comments, or TODO notes that
394 // contain neither sigil. `str::contains` is SIMD-accelerated and
395 // avoids the trim/strip chain below for the dominant case.
396 if !comment_text.contains("bca:") && !comment_text.contains("lizard") {
397 return MarkerScan::not_a_marker();
398 }
399
400 // Strip a `/*` opener and a `*/` closer if present so we don't
401 // confuse block-comment delimiters with marker prefixes.
402 let trimmed = strip_block_delims(comment_text.trim()).trim();
403
404 // Strip language-level comment openers *other than* `#`. We can't
405 // strip `#` here because Lizard's marker shape (`#lizard
406 // forgives`) needs the `#` to remain. In C++ `// #lizard ...`
407 // the `// ` must come off first so Lizard parsing sees `#lizard
408 // ...`. In Python `# #lizard ...` (the outer `#` is the language
409 // comment opener) tree-sitter delivers the raw `# #lizard ...`
410 // text — so the inner body still starts with `#`, which Lizard
411 // parsing wants. In both cases the no-`#` trim leaves the
412 // `#lizard` token intact.
413 // `!` is included so inner doc comments — `//! bca: suppress` and
414 // `/*! bca: suppress */` — strip down to the same body as their
415 // outer counterparts. Without this, the leading `!` would survive
416 // the strip and break the `bca:` prefix match.
417 let no_opener = trimmed
418 .trim_start_matches(|c: char| {
419 c == '/' || c == '*' || c == '!' || c == ';' || c == '-' || c.is_whitespace()
420 })
421 .trim_end_matches(|c: char| c == '*' || c == '/' || c.is_whitespace())
422 .trim();
423
424 // Python-style: tree-sitter delivers `# bca: suppress` with the
425 // leading `#` intact. Lizard expects `#lizard ...` — a literal
426 // `#` *followed by* `lizard`, no space. If the first `#` is the
427 // language's comment opener, strip exactly one `#` and any
428 // whitespace before retrying Lizard. The Python `# #lizard ...`
429 // shape is then also covered because two `#`s round-trip
430 // through one strip + one Lizard `#` prefix.
431 //
432 // Match `#l` only — Lizard's own scanner is case-sensitive
433 // (`parse_lizard` does `strip_prefix("lizard")`), so accepting
434 // `#L` here would just defer a failure to `parse_lizard`. Keeping
435 // the discriminator lowercase-only also matches the fast-bail
436 // above (`contains("lizard")`).
437 let lizard_candidate = if no_opener.starts_with("#l") {
438 // Already in `#lizard ...` shape after only block-delim
439 // stripping — typical for C++ where `// #lizard ...` has
440 // had `// ` removed above.
441 no_opener
442 } else if let Some(rest) = no_opener.strip_prefix('#') {
443 // Python/Bash style: `# #lizard ...` or `# bca: ...`. Drop
444 // the language comment opener; Lizard parsing only fires
445 // when what remains starts with another `#lizard`.
446 rest.trim_start()
447 } else {
448 no_opener
449 };
450
451 if let Some(suppression) = parse_lizard(lizard_candidate) {
452 return MarkerScan::directive(suppression);
453 }
454
455 // For native parsing, strip the same `#` opener so `# bca: suppress`
456 // matches. The remaining body is then checked for the `bca:`
457 // prefix.
458 let body = no_opener
459 .trim_start_matches(|c: char| c == '#' || c.is_whitespace())
460 .trim();
461
462 parse_native(body)
463}
464
465fn strip_block_delims(s: &str) -> &str {
466 let s = s.strip_prefix("/*").unwrap_or(s);
467 s.strip_suffix("*/").unwrap_or(s)
468}
469
470fn parse_lizard(trimmed: &str) -> Option<Suppression> {
471 // `#lizard forgives` — function-scoped, all metrics.
472 // `#lizard forgive global` — file-scoped, all metrics.
473 //
474 // Lizard's own scanner tolerates a single space after `#` and
475 // around the verb, but is otherwise exact. We mirror that by
476 // trimming the ends and matching the verb phrase verbatim.
477 let s = trimmed.strip_prefix('#')?.trim_start();
478 let s = s.strip_prefix("lizard")?;
479 let rest = s.trim();
480
481 if rest == "forgives" {
482 return Some(Suppression {
483 kind: SuppressionKind::Function,
484 scope: SuppressionScope::All,
485 source: SuppressionSource::Lizard,
486 });
487 }
488 if rest == "forgive global" {
489 return Some(Suppression {
490 kind: SuppressionKind::File,
491 scope: SuppressionScope::All,
492 source: SuppressionSource::Lizard,
493 });
494 }
495 None
496}
497
498fn parse_native(body: &str) -> MarkerScan {
499 // The native dialect is `bca:` followed by a verb (`suppress` or
500 // `suppress-file`), optionally followed by `(metric, metric, ...)`,
501 // optionally followed by a free-text rationale.
502 let Some(rest) = body.strip_prefix("bca:") else {
503 return MarkerScan::not_a_marker();
504 };
505 let rest = rest.trim_start();
506 if rest.is_empty() {
507 // A bare `bca:` with nothing after it isn't useful; treat as
508 // not-a-marker rather than an error so the user can write
509 // documentation that mentions the namespace without firing.
510 return MarkerScan::not_a_marker();
511 }
512
513 let malformed = || MarkerScan::rejected(SuppressionError::MalformedBody(body.to_owned()));
514
515 // Split into verb + parenthesised body. We accept whitespace
516 // between the verb and `(`. The verb is the longest prefix of
517 // ASCII letters and `-`.
518 let verb_end = rest
519 .find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
520 .unwrap_or(rest.len());
521 let (verb, after_verb) = rest.split_at(verb_end);
522
523 let kind = match verb {
524 "suppress" => SuppressionKind::Function,
525 "suppress-file" => SuppressionKind::File,
526 "" => return malformed(),
527 other => return MarkerScan::rejected(SuppressionError::UnknownVerb(other.to_owned())),
528 };
529
530 let after_verb = after_verb.trim_start();
531 let (scope, diagnostics) = if after_verb.is_empty() {
532 (SuppressionScope::All, Vec::new())
533 } else if let Some(list) = after_verb.strip_prefix('(') {
534 let Some(close) = list.find(')') else {
535 return malformed();
536 };
537 // Everything past the `)` is the author's rationale (issue
538 // #1168). The metric list already makes the intent unambiguous,
539 // so no separator is required and none is privileged: `— why`,
540 // `- why`, `: why`, `// why`, and bare prose all read the same.
541 // Rejecting them made `AGENTS.md`'s own "suppress with a reason"
542 // instruction produce a marker that silently did nothing.
543 let (metrics, diagnostics) = parse_metric_list(&list[..close]);
544 (SuppressionScope::Some(metrics), diagnostics)
545 } else {
546 // A bare verb followed by anything at all. Unlike the post-`)`
547 // case there is no positive signal here separating a rationale
548 // from prose that merely mentions the marker: the punctuation
549 // people reach for when writing *about* one (`-`, `:`, `//`,
550 // `#`, an em dash) is the same punctuation they would open a
551 // rationale with. Accepting either silences every metric on the
552 // enclosing function on the strength of a sentence, so the whole
553 // shape stays malformed and the author is told to name the
554 // metrics instead.
555 return malformed();
556 };
557
558 MarkerScan::partial(
559 Suppression {
560 kind,
561 scope,
562 source: SuppressionSource::Native,
563 },
564 diagnostics,
565 )
566}
567
568fn parse_metric_list(inside: &str) -> (BTreeSet<Metric>, Vec<SuppressionError>) {
569 let mut set = BTreeSet::new();
570 let mut diagnostics = Vec::new();
571 // A marker is free to repeat a name, and each unusable one costs a
572 // diagnostic carrying the full metric hint — so report each distinct
573 // name once and stop after `MAX_MARKER_DIAGNOSTICS` of them. Both
574 // guards bound the output by the marker's *vocabulary* rather than
575 // its length, which is what keeps an adversarial comment in an
576 // untrusted tree from flooding the log.
577 let mut reported: BTreeSet<&str> = BTreeSet::new();
578 let mut unusable = 0_usize;
579 for token in inside.split(',') {
580 let name = token.trim();
581 if name.is_empty() {
582 // Empty `()` or trailing commas: skip. An empty list
583 // suppresses nothing — equivalent to the marker being
584 // absent. We accept rather than error so authors can
585 // comment out parts of a list during editing.
586 continue;
587 }
588 // Parse through the canonical `Metric` vocabulary (the same one
589 // selection uses) so suppression and selection never drift. A
590 // typo surfaces the offending token via `ParseMetricError`
591 // (#554). `tokens` parses fine but has no threshold, so it gets
592 // a distinct, actionable diagnostic rather than silently
593 // registering a no-op suppression.
594 //
595 // A name we cannot honour is *skipped and reported*, not fatal
596 // to the whole list (issue #1168): `suppress(cognitive, exit)`
597 // still silences `cognitive`, because voiding the marker
598 // wholesale turned one mistyped name — `exit` for `nexits` is
599 // the documented one — into a suppression the author believed
600 // was active. Skipping can only ever narrow what a marker
601 // silences, so a typo cannot widen scope.
602 let unusable_name = match name.parse::<Metric>() {
603 Ok(Metric::Tokens) => SuppressionError::NonSuppressibleMetric(name.to_owned()),
604 Ok(metric) => {
605 set.insert(metric);
606 continue;
607 }
608 Err(_) => SuppressionError::UnknownMetric(name.to_owned()),
609 };
610 if reported.insert(name) {
611 unusable += 1;
612 if diagnostics.len() < MAX_MARKER_DIAGNOSTICS {
613 diagnostics.push(unusable_name);
614 }
615 }
616 }
617 let elided = unusable.saturating_sub(MAX_MARKER_DIAGNOSTICS);
618 if elided > 0 {
619 diagnostics.push(SuppressionError::ElidedDiagnostics(elided));
620 }
621 (set, diagnostics)
622}
623
624/// Whether an audited suppression marker applies to its enclosing
625/// function or to the whole file.
626///
627/// The public mirror of the crate-internal `SuppressionKind`; exposed
628/// on [`SuppressionMarker`] so the `bca exemptions` audit (issue #386)
629/// can report marker scope without leaking the internal type.
630// Deliberately exhaustive: function- and file-scope are the only two
631// suppression granularities the marker grammar models. A new granularity
632// would be a grammar-level change, planned deliberately, not a silent
633// additive variant.
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
635#[serde(rename_all = "snake_case")]
636pub enum SuppressionTarget {
637 /// Marker silences thresholds for its enclosing function only.
638 Function,
639 /// Marker silences thresholds for the whole file.
640 File,
641}
642
643impl From<SuppressionKind> for SuppressionTarget {
644 fn from(kind: SuppressionKind) -> Self {
645 match kind {
646 SuppressionKind::Function => Self::Function,
647 SuppressionKind::File => Self::File,
648 }
649 }
650}
651
652/// Which marker dialect produced a suppression.
653///
654/// The public mirror of the crate-internal `SuppressionSource`;
655/// exposed on [`SuppressionMarker`] so an audit can flag Lizard-style
656/// markers that projects may want to migrate to the native `bca:`
657/// dialect over time.
658// New tool dialects beyond Native and Lizard are plausible (other
659// linters with their own forgive-marker syntax), so this carries
660// `#[non_exhaustive]` to keep such additions additive rather than a 2.0
661// break. The CLI `marker_label` tuple match has a `_ =>` arm for the
662// not-yet-known dialects.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
664#[serde(rename_all = "snake_case")]
665#[non_exhaustive]
666pub enum SuppressionDialect {
667 /// Native `bca:` marker.
668 Native,
669 /// Lizard compatibility marker (`#lizard forgives`).
670 Lizard,
671}
672
673impl From<SuppressionSource> for SuppressionDialect {
674 fn from(source: SuppressionSource) -> Self {
675 match source {
676 SuppressionSource::Native => Self::Native,
677 SuppressionSource::Lizard => Self::Lizard,
678 }
679 }
680}
681
682/// A single in-source suppression marker located within a file, carrying
683/// the context needed to audit it.
684///
685/// Produced by [`crate::Ast::suppressions`] for the `bca exemptions`
686/// report (issue #386). Unlike the
687/// merged [`crate::FuncSpace::suppressed`] scope — which records only
688/// *what* a function ends up suppressing — this records each marker's
689/// own location, dialect, and the enclosing function it was written in,
690/// so reviewers can see every silencer in the tree, not just its net
691/// effect.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
693pub struct SuppressionMarker {
694 /// 1-based line of the comment that carries the marker.
695 pub line: usize,
696 /// Whether the marker is function- or file-scoped.
697 pub target: SuppressionTarget,
698 /// Which metrics the marker covers (`all` or a named set).
699 pub scope: SuppressionScope,
700 /// Native vs Lizard dialect.
701 pub dialect: SuppressionDialect,
702 /// Enclosing function name for a function-scoped marker, if the
703 /// marker sits inside a function body. `None` for file-scoped
704 /// markers (whole-file by definition) and for function-scoped
705 /// markers written outside any function (which silence nothing — a
706 /// dead marker worth surfacing in an audit).
707 pub function: Option<String>,
708}
709
710/// Collect every in-source suppression marker in a parsed file, with the
711/// location and enclosing-function context the `bca exemptions` audit
712/// reports (issue #386).
713///
714/// The walk mirrors the comment-scanning step in
715/// [`crate::analyze`] / [`crate::Ast::metrics`]: it visits comment nodes,
716/// parses each through [`parse_marker`], and records the successes.
717/// Malformed native markers are skipped silently here — the audit is a
718/// read-only listing of what *is* a marker, and the threshold walk is
719/// the surface that already warns on malformed bodies.
720///
721/// Enclosing-function attribution tracks the syntactically nearest
722/// function ancestor during a depth-first walk, matching the body-
723/// containment rule the real suppression logic uses (issue #289) rather
724/// than line-range guessing. Markers are returned sorted by line.
725///
726/// Crate-internal walk core reached through the
727/// [`crate::Ast::suppressions`] seam.
728#[must_use]
729pub(crate) fn suppression_markers<T: ParserTrait>(parser: &T) -> Vec<SuppressionMarker> {
730 let code = parser.code();
731 let mut markers = Vec::new();
732 // Ancestor chain of the node currently being visited, root first.
733 // Maintained by the same truncate/push rule as
734 // `spaces::compute::metrics_inner`: this walk is pre-order, so every
735 // ancestor has already been visited and appended, and truncating to
736 // the node's depth drops the sibling subtree just finished (#1084).
737 let mut chain: Vec<Node<'_>> = Vec::new();
738 // Explicit-stack DFS (not recursion) so a pathologically deep AST
739 // cannot overflow the call stack. Each frame carries the nearest
740 // enclosing function name, borrowed from `code`, so child nodes
741 // inherit it without re-deriving, plus the node's depth, which
742 // indexes `chain`.
743 let root = parser.root();
744 let mut stack: Vec<(Node<'_>, Option<&str>, usize)> = vec![(root, None, 0)];
745 // One cursor for the whole walk, not one per node: this visits every
746 // node in the file, and `Node::children` would build and free a
747 // `TreeCursor` at each (#1112, `Node::children_with`).
748 let mut cursor = root.cursor();
749 while let Some((node, enclosing, depth)) = stack.pop() {
750 chain.truncate(depth);
751
752 if let Some(marker) = marker_at::<T>(&node, code, enclosing) {
753 markers.push(marker);
754 }
755 // `is_func_with_code` rather than `is_func`: C/C++ identify
756 // functions only via the code-aware predicate, and the default
757 // impl delegates to `is_func` for every other language. The
758 // predicates that consult an ancestor — Elixir's `quote`
759 // template check, the JS-family name-binding walk — read it off
760 // `chain` rather than climbing with `Node::parent` (#1088).
761 let ancestors = Ancestors::checked(&chain, &node);
762 let child_enclosing = if T::Checker::is_func_with_code(&node, code, ancestors) {
763 T::Getter::get_func_name(&node, code, ancestors).or(enclosing)
764 } else {
765 enclosing
766 };
767 chain.push(node);
768 stack.extend(
769 node.children_with(&mut cursor)
770 .map(|child| (child, child_enclosing, depth + 1)),
771 );
772 }
773 markers.sort_by_key(|m| m.line);
774 markers
775}
776
777/// The suppression marker `node` carries, if it is a comment holding a
778/// well-formed one.
779///
780/// `enclosing` names the syntactically nearest enclosing function.
781/// File-scoped markers are whole-file by definition, so the enclosing
782/// function is irrelevant to them and reported as `None` rather than as
783/// a misleading "inside fn X".
784///
785/// A malformed native marker yields `None`: the audit is a read-only
786/// listing of what *is* a marker, and the threshold walk is the surface
787/// that already warns on malformed bodies.
788fn marker_at<T: ParserTrait>(
789 node: &Node<'_>,
790 code: &[u8],
791 enclosing: Option<&str>,
792) -> Option<SuppressionMarker> {
793 if !T::Checker::is_comment(node) {
794 return None;
795 }
796 let suppression = parse_marker(node.utf8_text(code)?).suppression?;
797 let function = match suppression.kind {
798 SuppressionKind::Function => enclosing.map(str::to_owned),
799 SuppressionKind::File => None,
800 };
801 Some(SuppressionMarker {
802 line: node.start_row() + 1,
803 target: suppression.kind.into(),
804 scope: suppression.scope,
805 dialect: suppression.source.into(),
806 function,
807 })
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 /// The directive `text` parses to, asserting it carried one and drew
815 /// no complaint. Use where the subject is what a *clean* marker
816 /// means; anything expecting a diagnostic should read
817 /// [`scan_diagnostics`] instead so the complaint is asserted, not
818 /// discarded.
819 #[track_caller]
820 fn marker(text: &str) -> Suppression {
821 let scan = parse_marker(text);
822 assert!(
823 scan.diagnostics.is_empty(),
824 "expected a clean parse of {text:?}; got {:?}",
825 scan.diagnostics,
826 );
827 scan.suppression
828 .unwrap_or_else(|| panic!("expected {text:?} to parse as a marker"))
829 }
830
831 /// Every complaint `text` drew.
832 fn scan_diagnostics(text: &str) -> Vec<SuppressionError> {
833 parse_marker(text).diagnostics
834 }
835
836 /// Whether `text` is no marker at all: no directive *and* no
837 /// complaint. Both halves matter — a comment that merely mentions
838 /// the syntax must stay silent, not warn at every reader.
839 fn is_not_a_marker(text: &str) -> bool {
840 let scan = parse_marker(text);
841 scan.suppression.is_none() && scan.diagnostics.is_empty()
842 }
843
844 /// The single complaint `text` drew, when exactly one is expected.
845 #[track_caller]
846 fn sole_diagnostic(text: &str) -> SuppressionError {
847 let mut diagnostics = scan_diagnostics(text);
848 assert_eq!(
849 diagnostics.len(),
850 1,
851 "expected exactly one diagnostic for {text:?}; got {diagnostics:?}",
852 );
853 diagnostics.remove(0)
854 }
855
856 /// The complaint `text` drew, asserting it also voided the marker
857 /// outright — the shape reserved for a body that parses to no
858 /// directive at all.
859 #[track_caller]
860 fn voiding_diagnostic(text: &str) -> SuppressionError {
861 let scan = parse_marker(text);
862 assert!(
863 scan.suppression.is_none(),
864 "expected {text:?} to yield no directive; got {:?}",
865 scan.suppression,
866 );
867 sole_diagnostic(text)
868 }
869
870 #[test]
871 fn native_bare_suppress_covers_all_for_function() {
872 let s = marker("// bca: suppress");
873 assert_eq!(s.kind, SuppressionKind::Function);
874 assert_eq!(s.source, SuppressionSource::Native);
875 assert!(matches!(s.scope, SuppressionScope::All));
876 }
877
878 #[test]
879 fn native_suppress_with_metric_list() {
880 let s = marker("// bca: suppress(cyclomatic, cognitive)");
881 assert_eq!(s.kind, SuppressionKind::Function);
882 let SuppressionScope::Some(metrics) = s.scope else {
883 panic!("expected Some(...)");
884 };
885 assert!(metrics.contains(&Metric::Cyclomatic));
886 assert!(metrics.contains(&Metric::Cognitive));
887 assert_eq!(metrics.len(), 2);
888 }
889
890 #[test]
891 fn native_mixed_valid_and_unknown_metric_keeps_the_valid_half() {
892 // Issue #1168 reversed the pre-existing void-on-typo contract
893 // (#948, #896). A misspelled name now costs its own name and
894 // nothing else: `exit`-for-`nexits` is a mistake `AGENTS.md`
895 // itself documents people making, and voiding the marker
896 // wholesale turned it into a suppression the author believed was
897 // active while the gate disagreed.
898 //
899 // Skipping cannot widen scope — the set only ever loses entries
900 // — which is what made the old contract's stated danger ("a typo
901 // silences something the author did not name") unreachable here.
902 // Every other test feeds a marker whose only metric is unknown,
903 // where "void the marker" and "skip the token" are
904 // indistinguishable; only a mixed list separates them.
905 let scan = parse_marker("// bca: suppress(cyclomatic, no_such_metric)");
906 let Some(Suppression {
907 scope: SuppressionScope::Some(metrics),
908 ..
909 }) = &scan.suppression
910 else {
911 panic!(
912 "expected an explicit metric set; got {:?}",
913 scan.suppression
914 );
915 };
916 assert_eq!(
917 metrics.iter().copied().collect::<Vec<_>>(),
918 vec![Metric::Cyclomatic],
919 "the recognized half of the list must still suppress",
920 );
921 assert!(
922 matches!(
923 scan.diagnostics.as_slice(),
924 [SuppressionError::UnknownMetric(name)] if name == "no_such_metric",
925 ),
926 "the unrecognized half must still be reported; got {:?}",
927 scan.diagnostics,
928 );
929 }
930
931 #[test]
932 fn native_suppress_file_bare() {
933 let s = marker("# bca: suppress-file");
934 assert_eq!(s.kind, SuppressionKind::File);
935 assert!(matches!(s.scope, SuppressionScope::All));
936 }
937
938 #[test]
939 fn native_suppress_file_with_metric_list() {
940 let s = marker("/* bca: suppress-file(halstead, loc) */");
941 assert_eq!(s.kind, SuppressionKind::File);
942 let SuppressionScope::Some(metrics) = s.scope else {
943 panic!("expected Some(...)");
944 };
945 assert!(metrics.contains(&Metric::Halstead));
946 assert!(metrics.contains(&Metric::Loc));
947 }
948
949 #[test]
950 fn native_unknown_metric_errors() {
951 let err = sole_diagnostic("// bca: suppress(no_such_metric)");
952 assert!(matches!(err, SuppressionError::UnknownMetric(_)));
953 // The error must mention what was unknown so authors can
954 // diagnose typos without reading our source. This is the #554
955 // acceptance: the offending token is surfaced (it now flows out
956 // of `Metric::from_str`'s `ParseMetricError`, not a `()` error).
957 let rendered = err.to_string();
958 assert!(rendered.contains("no_such_metric"));
959 // And it must list the known metrics so a fix is one
960 // copy-paste away.
961 assert!(rendered.contains("cyclomatic"));
962 // The non-suppressible `tokens` must NOT appear in the hint —
963 // suggesting it would be misleading.
964 assert!(
965 !rendered.contains("tokens"),
966 "hint must omit the non-suppressible `tokens`; got: {rendered}",
967 );
968 // The hint must be derived from `Metric::suppressible()` (the
969 // documented single source of truth), not a re-derived list.
970 // Guards #805: every suppressible metric appears, alphabetised.
971 let mut expected: Vec<String> = Metric::suppressible()
972 .map(|metric| metric.to_string())
973 .collect();
974 expected.sort_unstable();
975 assert!(
976 rendered.ends_with(&format!("known metrics: {}", expected.join(", "))),
977 "hint must list exactly the suppressible metrics from \
978 `Metric::suppressible()`, alphabetised; got: {rendered}",
979 );
980 }
981
982 #[test]
983 fn native_tokens_is_not_suppressible() {
984 // `tokens` parses as a real `Metric` but has no threshold, so a
985 // marker naming it is rejected with a distinct, actionable error
986 // rather than silently accepted as a no-op suppression.
987 let err = sole_diagnostic("// bca: suppress(tokens)");
988 assert!(
989 matches!(&err, SuppressionError::NonSuppressibleMetric(m) if m == "tokens"),
990 "expected NonSuppressibleMetric(\"tokens\"); got: {err:?}",
991 );
992 let rendered = err.to_string();
993 assert!(rendered.contains("tokens"));
994 assert!(
995 rendered.contains("no threshold"),
996 "message must explain why tokens cannot be suppressed; got: {rendered}",
997 );
998 }
999
1000 #[test]
1001 fn native_unknown_verb_errors() {
1002 let err = voiding_diagnostic("// bca: disable");
1003 assert!(matches!(err, SuppressionError::UnknownVerb(_)));
1004 // The error message must guide the author toward the correct
1005 // verbs without making them grep our source. Anchor each verb
1006 // with its surrounding backticks so the bare `suppress` check
1007 // can't be silently satisfied by the substring inside
1008 // `suppress-file` — a future message that drops the bare verb
1009 // and keeps only the compound one would otherwise pass this
1010 // assertion.
1011 let rendered = err.to_string();
1012 assert!(
1013 rendered.contains("`suppress`"),
1014 "expected message to name the bare `suppress` verb; got: {rendered}"
1015 );
1016 assert!(
1017 rendered.contains("`suppress-file`"),
1018 "expected message to name the `suppress-file` verb; got: {rendered}"
1019 );
1020 }
1021
1022 /// Locks the hard rename in issue #263: the previous spelling
1023 /// `// bca: allow` (and `// bca: allow-file`) must no longer be
1024 /// recognized. They now fall through to `UnknownVerb`, the same
1025 /// path as any other typo. A future revert that re-adds the old
1026 /// verb to the match would silently re-enable old-style markers
1027 /// in shipped source; this test catches that.
1028 #[test]
1029 fn legacy_allow_verb_is_unknown() {
1030 let err = voiding_diagnostic("// bca: allow");
1031 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
1032 let err = voiding_diagnostic("// bca: allow-file");
1033 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow-file"));
1034 let err = voiding_diagnostic("// bca: allow(cyclomatic)");
1035 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
1036 }
1037
1038 #[test]
1039 fn native_malformed_body_errors() {
1040 // Unbalanced paren: there is no metric list to honour and no way
1041 // to tell where one would have ended, so the marker is void.
1042 assert!(matches!(
1043 voiding_diagnostic("// bca: suppress(cyclomatic"),
1044 SuppressionError::MalformedBody(_)
1045 ));
1046 // Bare verb followed by a word. With no metric list to anchor
1047 // the intent, `// bca: suppress markers are honoured here` is
1048 // prose about the feature, and reading it as a marker would
1049 // silence every metric in the enclosing function.
1050 assert!(matches!(
1051 voiding_diagnostic("// bca: suppress garbage"),
1052 SuppressionError::MalformedBody(_)
1053 ));
1054 }
1055
1056 #[test]
1057 fn malformed_body_message_names_the_accepted_shapes() {
1058 // This warning is now the only signal an author gets that the
1059 // reason they wrote after a bare verb left the marker inert, so
1060 // it must name the shapes that parse *and* both ways out: name
1061 // the metrics, or move the reason off the marker line.
1062 let rendered = voiding_diagnostic("// bca: suppress - see #123").to_string();
1063 assert!(
1064 rendered.contains("bca: suppress - see #123"),
1065 "message must echo the offending body; got: {rendered}",
1066 );
1067 assert!(
1068 rendered.contains("`bca: suppress(<metrics>)`"),
1069 "message must name the metric-list shape; got: {rendered}",
1070 );
1071 assert!(
1072 rendered.contains("rationale"),
1073 "message must point at the rationale form; got: {rendered}",
1074 );
1075 assert!(
1076 rendered.contains("name the metrics"),
1077 "message must tell the author to name the metrics; got: {rendered}",
1078 );
1079 assert!(
1080 rendered.contains("line above"),
1081 "message must offer the move-the-reason-up escape; got: {rendered}",
1082 );
1083 }
1084
1085 #[test]
1086 fn native_bare_colon_is_not_a_marker() {
1087 // `bca:` with nothing after it is not a marker; we want to
1088 // allow documentation comments to mention the namespace.
1089 let scan = parse_marker("// bca:");
1090 assert_eq!(scan.suppression, None);
1091 assert!(scan.diagnostics.is_empty());
1092 }
1093
1094 #[test]
1095 fn empty_metric_list_is_noop_not_error() {
1096 let s = marker("// bca: suppress()");
1097 assert!(s.scope.is_empty());
1098 assert!(!s.scope.covers(Metric::Cyclomatic));
1099 }
1100
1101 #[test]
1102 fn trailing_rationale_after_metric_list_is_accepted() {
1103 // The issue #1168 reproducer, at the parse boundary: the
1104 // spelling `AGENTS.md` asks for — a metric list plus the reason
1105 // the function is exempt — used to be rejected wholesale, so the
1106 // author's suppression silently did nothing.
1107 //
1108 // No separator is privileged and none is required: after `)` the
1109 // author has already said what they mean, so anything following
1110 // is prose.
1111 for text in [
1112 "// bca: suppress(nargs) \u{2014} threaded context, not a god-function",
1113 "// bca: suppress(nargs) \u{2013} threaded context",
1114 "// bca: suppress(nargs) - threaded context",
1115 "// bca: suppress(nargs): threaded context",
1116 "// bca: suppress(nargs) // threaded context",
1117 "// bca: suppress(nargs) threaded context",
1118 "/* bca: suppress(nargs) \u{2014} threaded context */",
1119 ] {
1120 let s = marker(text);
1121 assert_eq!(s.kind, SuppressionKind::Function, "for {text:?}");
1122 assert!(
1123 matches!(&s.scope, SuppressionScope::Some(m)
1124 if m.iter().copied().eq([Metric::Nargs])),
1125 "rationale must not disturb the metric list; {text:?} gave {:?}",
1126 s.scope,
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn a_bare_verb_takes_no_trailing_text_whatever_the_separator() {
1133 // #1168 briefly accepted a rationale after a bare verb when it
1134 // opened with `-`, `:`, `//`, `#`, or an em/en dash. Those are
1135 // exactly the characters people reach for when writing *about* a
1136 // marker, so ordinary comments silenced every metric on their
1137 // function with no diagnostic at all. There is no positive
1138 // signal in this shape to separate the two readings — the
1139 // parentheses of the list form are what supply one — so every
1140 // row below is malformed, including the paths and prose that
1141 // never were rationales.
1142 for text in [
1143 "// bca: suppress \u{2014} irreducible dispatch",
1144 "// bca: suppress \u{2013} irreducible dispatch",
1145 "// bca: suppress - we removed this marker, see #123",
1146 "// bca: suppress: not applicable to this function",
1147 "// bca: suppress // generated shim",
1148 "// bca: suppress /some/path",
1149 "// bca: suppress markers are honoured here",
1150 "# bca: suppress-file # generated",
1151 "// bca: suppress-file generated file",
1152 ] {
1153 let scan = parse_marker(text);
1154 assert_eq!(
1155 scan.suppression, None,
1156 "a bare verb plus trailing text must not suppress; {text:?}",
1157 );
1158 assert!(
1159 matches!(
1160 scan.diagnostics.as_slice(),
1161 [SuppressionError::MalformedBody(_)]
1162 ),
1163 "{text:?} must warn that the marker is inert; got {:?}",
1164 scan.diagnostics,
1165 );
1166 }
1167 // Positive control: a parser that rejected everything would pass
1168 // the loop above. The verb alone still suppresses, and so does a
1169 // metric list carrying the rationale that replaces this shape.
1170 assert!(matches!(
1171 marker("// bca: suppress").scope,
1172 SuppressionScope::All
1173 ));
1174 assert!(matches!(
1175 marker("// bca: suppress(nargs) \u{2014} threaded context").scope,
1176 SuppressionScope::Some(_)
1177 ));
1178 }
1179
1180 #[test]
1181 fn unusable_names_are_deduplicated_and_capped_per_marker() {
1182 // One diagnostic per *distinct* unusable name, not per token.
1183 // Each renders the full suppressible-metric hint, so an
1184 // unbounded marker in an untrusted tree is a log flood rather
1185 // than a typo report.
1186 let repeated = ["nope"; 500].join(",");
1187 let scan = parse_marker(&format!("// bca: suppress({repeated})"));
1188 assert_eq!(
1189 scan.diagnostics,
1190 vec![SuppressionError::UnknownMetric("nope".to_owned())],
1191 "500 copies of one name must cost exactly one diagnostic",
1192 );
1193
1194 // Distinct names past the cap are elided, but the tail is
1195 // *counted*: a silent truncation would understate the marker.
1196 let overflow = 5;
1197 let distinct: Vec<String> = (0..MAX_MARKER_DIAGNOSTICS + overflow)
1198 .map(|i| format!("nope{i}"))
1199 .collect();
1200 let scan = parse_marker(&format!("// bca: suppress({})", distinct.join(",")));
1201 assert_eq!(
1202 scan.diagnostics.len(),
1203 MAX_MARKER_DIAGNOSTICS + 1,
1204 "expected {MAX_MARKER_DIAGNOSTICS} names plus one tail; got {:?}",
1205 scan.diagnostics,
1206 );
1207 assert_eq!(
1208 scan.diagnostics.last(),
1209 Some(&SuppressionError::ElidedDiagnostics(overflow)),
1210 "the elided count must survive the cap; got {:?}",
1211 scan.diagnostics,
1212 );
1213 // Render it. The variant assertion above holds even if the tail
1214 // formats as an empty string, and this is the one diagnostic a
1215 // reader only ever meets in the pathological case the cap exists
1216 // for — so the count has to reach the page, not just the struct.
1217 let tail = scan
1218 .diagnostics
1219 .last()
1220 .expect("the cap always appends a tail")
1221 .to_string();
1222 assert!(
1223 tail.contains(&overflow.to_string()),
1224 "the rendered tail must name how many were elided; got {tail:?}",
1225 );
1226 assert!(
1227 tail.contains("more unusable metric name"),
1228 "the rendered tail must say what was elided; got {tail:?}",
1229 );
1230 // The cap never touches the metrics the marker really names.
1231 let scan = parse_marker(&format!(
1232 "// bca: suppress(cognitive,{})",
1233 distinct.join(",")
1234 ));
1235 let Some(Suppression {
1236 scope: SuppressionScope::Some(metrics),
1237 ..
1238 }) = &scan.suppression
1239 else {
1240 panic!(
1241 "expected an explicit metric set; got {:?}",
1242 scan.suppression
1243 );
1244 };
1245 assert!(
1246 metrics.contains(&Metric::Cognitive),
1247 "capping diagnostics must not narrow the suppression; got {metrics:?}",
1248 );
1249 }
1250
1251 #[test]
1252 fn rationale_may_contain_parentheses_and_marker_syntax() {
1253 // The metric list ends at the first `)`, so a rationale is free
1254 // to contain further parens, and a second `suppress(` inside it
1255 // is prose rather than a nested directive: one comment carries
1256 // at most one marker.
1257 let s = marker("// bca: suppress(nargs) — mirrors suppress(abc) in do_thing(x)");
1258 assert!(
1259 matches!(&s.scope, SuppressionScope::Some(m)
1260 if m.iter().copied().eq([Metric::Nargs])),
1261 "got {:?}",
1262 s.scope,
1263 );
1264 }
1265
1266 #[test]
1267 fn rationale_survives_a_flawed_metric_list() {
1268 // The two #1168 halves compose: a rationale is accepted *and*
1269 // the recognized metrics still suppress while the rest is
1270 // reported. Neither relaxation is allowed to swallow the other.
1271 let scan = parse_marker("// bca: suppress(cognitive, exit) — hand-rolled state machine");
1272 assert!(
1273 matches!(&scan.suppression, Some(s)
1274 if matches!(&s.scope, SuppressionScope::Some(m)
1275 if m.iter().copied().eq([Metric::Cognitive]))),
1276 "got {:?}",
1277 scan.suppression,
1278 );
1279 assert!(
1280 matches!(
1281 scan.diagnostics.as_slice(),
1282 [SuppressionError::UnknownMetric(name)] if name == "exit",
1283 ),
1284 "`exit` is the documented `nexits` typo and must still be \
1285 reported; got {:?}",
1286 scan.diagnostics,
1287 );
1288 }
1289
1290 #[test]
1291 fn whitespace_only_rationale_is_not_a_diagnostic() {
1292 // Trailing whitespace after the list — a stray tab before the
1293 // newline, say — is not a rationale and must not read as one.
1294 let s = marker("// bca: suppress(nargs) \t ");
1295 assert!(matches!(&s.scope, SuppressionScope::Some(m) if m.len() == 1));
1296 }
1297
1298 #[test]
1299 fn lizard_function_marker() {
1300 let s = marker("// #lizard forgives");
1301 assert_eq!(s.kind, SuppressionKind::Function);
1302 assert_eq!(s.source, SuppressionSource::Lizard);
1303 assert!(matches!(s.scope, SuppressionScope::All));
1304 }
1305
1306 #[test]
1307 fn lizard_file_marker() {
1308 let s = marker("# #lizard forgive global");
1309 assert_eq!(s.kind, SuppressionKind::File);
1310 assert_eq!(s.source, SuppressionSource::Lizard);
1311 }
1312
1313 #[test]
1314 fn lizard_unknown_phrase_is_not_a_marker() {
1315 // Per the issue's narrow compat surface: `#lizard skip` is not
1316 // a recognized Lizard directive, so we treat it as no marker
1317 // rather than erroring or silently suppressing.
1318 assert!(is_not_a_marker("// #lizard skip"));
1319 }
1320
1321 #[test]
1322 fn plain_comment_is_not_a_marker() {
1323 assert!(is_not_a_marker("// just a comment"));
1324 assert!(is_not_a_marker("/* TODO: fix later */"));
1325 }
1326
1327 /// Locks the fast-bail contract in `parse_marker`: comments that
1328 /// contain neither `bca:` nor `lizard` must short-circuit to
1329 /// `Ok(None)`. A future change broadening the substring check
1330 /// (case-insensitive, etc.) would silently shift parsing semantics
1331 /// for comments that mention `Bca:` or `Lizard` in prose; this
1332 /// test catches that.
1333 #[test]
1334 fn fast_bail_skips_sigil_free_comments() {
1335 // Long, sigil-free comments that should never trigger.
1336 assert!(is_not_a_marker("// Copyright (c) 2026 Some Corp."));
1337 assert!(is_not_a_marker("/* SPDX-License-Identifier: MIT */"));
1338 // Substring-mention-but-not-a-marker: contains "lizard" in
1339 // prose but is not a Lizard directive. Slow path must still
1340 // return Ok(None).
1341 assert!(is_not_a_marker("// authors: jane lizard, john doe"));
1342 }
1343
1344 /// Locks the case sensitivity of both dialects: `Bca:` and
1345 /// `#Lizard` must NOT be recognized. Both the fast-bail and the
1346 /// underlying parsers are lowercase-only by design; this test
1347 /// pins that contract.
1348 #[test]
1349 fn marker_grammar_is_case_sensitive() {
1350 // Uppercase B in `Bca:` is not a native marker.
1351 assert!(is_not_a_marker("// Bca: suppress"));
1352 assert!(is_not_a_marker("/* BCA: suppress */"));
1353 // Uppercase L in `#Lizard` is not a Lizard marker. The
1354 // fast-bail rejects it (no lowercase "lizard" substring) and
1355 // the slow path would also reject it via `strip_prefix("lizard")`.
1356 assert!(is_not_a_marker("# #Lizard forgives"));
1357 assert!(is_not_a_marker("// #Lizard forgives"));
1358 }
1359
1360 #[test]
1361 fn scope_merge_all_absorbs() {
1362 let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
1363 a.merge(&SuppressionScope::All);
1364 assert!(a.is_all());
1365
1366 let mut b = SuppressionScope::All;
1367 b.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Loc])));
1368 assert!(b.is_all());
1369 }
1370
1371 #[test]
1372 fn scope_merge_some_unions() {
1373 let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
1374 a.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Cognitive])));
1375 assert!(a.covers(Metric::Loc));
1376 assert!(a.covers(Metric::Cognitive));
1377 assert!(!a.covers(Metric::Cyclomatic));
1378 }
1379
1380 #[test]
1381 fn scope_covers_respects_all_vs_some() {
1382 assert!(SuppressionScope::All.covers(Metric::Cyclomatic));
1383 let some = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
1384 assert!(some.covers(Metric::Loc));
1385 assert!(!some.covers(Metric::Cyclomatic));
1386 }
1387
1388 #[test]
1389 fn scope_serialization_uses_canonical_names_and_stable_order() {
1390 // The serialized `Some` scope must (a) spell metrics with their
1391 // canonical names — `nexits`, not `n_exits` or the legacy `exit`
1392 // — and (b) iterate in deterministic `Ord` (declaration) order so
1393 // snapshots are stable. Insert in scrambled order to prove the
1394 // ordering comes from `BTreeSet<Metric>`, not insertion order.
1395 let scope = SuppressionScope::Some(BTreeSet::from([
1396 Metric::Wmc,
1397 Metric::Nexits,
1398 Metric::Nargs,
1399 Metric::Cognitive,
1400 ]));
1401 let json = serde_json::to_string(&scope).unwrap();
1402 assert_eq!(
1403 json,
1404 r#"{"kind":"some","metrics":["cognitive","nargs","nexits","wmc"]}"#,
1405 );
1406 // Round-trips back to the same scope.
1407 let back: SuppressionScope = serde_json::from_str(&json).unwrap();
1408 assert_eq!(back, scope);
1409 }
1410
1411 #[test]
1412 fn for_threshold_name_maps_dotted_subnames_to_families() {
1413 // Cyclomatic.modified and cyclomatic both fall under
1414 // Metric::Cyclomatic — silencing `cyclomatic` covers the
1415 // modified variant too. Same for halstead.* and loc.*.
1416 assert_eq!(
1417 threshold_metric_for_name("cyclomatic"),
1418 Some(Metric::Cyclomatic)
1419 );
1420 assert_eq!(
1421 threshold_metric_for_name("cyclomatic.modified"),
1422 Some(Metric::Cyclomatic)
1423 );
1424 assert_eq!(
1425 threshold_metric_for_name("halstead.volume"),
1426 Some(Metric::Halstead)
1427 );
1428 assert_eq!(threshold_metric_for_name("loc.lloc"), Some(Metric::Loc));
1429 }
1430
1431 #[test]
1432 fn for_threshold_name_resolves_nexits_canonically() {
1433 // Post-#555 the suppression vocabulary uses the same canonical
1434 // `nexits` spelling as the threshold engine — no `exit` alias
1435 // bridge. `bca: suppress(nexits)` silences a `nexits` threshold
1436 // violation directly.
1437 assert_eq!(threshold_metric_for_name("nexits"), Some(Metric::Nexits));
1438 }
1439
1440 #[test]
1441 fn for_threshold_name_returns_none_for_unknown() {
1442 // `tokens` is in the threshold registry but is non-suppressible
1443 // (no configurable threshold). Treat as "no metric family" so a
1444 // marker can't silence the threshold; this mirrors the parse-side
1445 // rejection of `bca: suppress(tokens)`.
1446 assert_eq!(threshold_metric_for_name("tokens"), None);
1447 assert_eq!(threshold_metric_for_name("no_such_metric"), None);
1448 }
1449
1450 #[test]
1451 fn default_scope_is_empty() {
1452 let d = SuppressionScope::default();
1453 assert!(d.is_empty());
1454 assert!(!d.is_all());
1455 }
1456
1457 #[test]
1458 fn inner_doc_comments_recognized() {
1459 // Rust inner doc comments (`//!`, `/*!`) are the same shape as
1460 // their outer counterparts (`///`, `/**`) modulo the `!` byte.
1461 // Without `!` in the leading-strip set the marker prefix `bca:`
1462 // would not match. Both line- and block-comment variants must
1463 // round-trip the same way.
1464 let line = marker("//! bca: suppress");
1465 assert_eq!(line.kind, SuppressionKind::Function);
1466 assert!(matches!(line.scope, SuppressionScope::All));
1467
1468 let block = marker("/*! bca: suppress */");
1469 assert_eq!(block.kind, SuppressionKind::Function);
1470 assert!(matches!(block.scope, SuppressionScope::All));
1471 }
1472
1473 use crate::{CppParser, ElixirParser, PythonParser, RustParser};
1474 use std::path::PathBuf;
1475
1476 /// Collect markers from a Rust snippet via the public collector.
1477 fn rust_markers(src: &str) -> Vec<SuppressionMarker> {
1478 let parser = RustParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.rs"), None);
1479 suppression_markers(&parser)
1480 }
1481
1482 #[test]
1483 fn collector_function_scoped_native_marker_attributes_enclosing_fn() {
1484 // The marker sits inside `do_thing`'s body, so the audit must
1485 // attribute it to that function — the body-containment rule, not
1486 // a line-range guess.
1487 let src = "fn do_thing() {\n // bca: suppress\n let x = 1;\n}\n";
1488 let markers = rust_markers(src);
1489 assert_eq!(markers.len(), 1);
1490 let m = &markers[0];
1491 assert_eq!(m.line, 2);
1492 assert_eq!(m.target, SuppressionTarget::Function);
1493 assert_eq!(m.dialect, SuppressionDialect::Native);
1494 assert!(matches!(m.scope, SuppressionScope::All));
1495 assert_eq!(m.function.as_deref(), Some("do_thing"));
1496 }
1497
1498 #[test]
1499 fn collector_metric_list_scope_is_preserved() {
1500 let src = "fn f() {\n // bca: suppress(cyclomatic, cognitive)\n}\n";
1501 let markers = rust_markers(src);
1502 assert_eq!(markers.len(), 1);
1503 let SuppressionScope::Some(metrics) = &markers[0].scope else {
1504 panic!("expected an explicit metric set");
1505 };
1506 assert!(metrics.contains(&Metric::Cyclomatic));
1507 assert!(metrics.contains(&Metric::Cognitive));
1508 assert_eq!(metrics.len(), 2);
1509 }
1510
1511 #[test]
1512 fn collector_file_scoped_marker_has_no_enclosing_fn() {
1513 // A `suppress-file` marker is whole-file by definition; the
1514 // enclosing function must be elided even though it is written
1515 // inside a function body.
1516 let src = "fn f() {\n // bca: suppress-file\n}\n";
1517 let markers = rust_markers(src);
1518 assert_eq!(markers.len(), 1);
1519 assert_eq!(markers[0].target, SuppressionTarget::File);
1520 assert_eq!(markers[0].function, None);
1521 }
1522
1523 #[test]
1524 fn collector_nested_fn_attributes_innermost() {
1525 // The marker is inside the inner function; attribution must pick
1526 // the syntactically nearest enclosing function, not the outer.
1527 let src = "fn outer() {\n fn inner() {\n // bca: suppress\n }\n}\n";
1528 let markers = rust_markers(src);
1529 assert_eq!(markers.len(), 1);
1530 assert_eq!(markers[0].function.as_deref(), Some("inner"));
1531 }
1532
1533 #[test]
1534 fn collector_marker_outside_any_fn_has_no_enclosing_fn() {
1535 // A function-scoped marker with no enclosing function silences
1536 // nothing; the audit still lists it (a dead marker) with no
1537 // function attribution.
1538 let src = "// bca: suppress\nfn f() {}\n";
1539 let markers = rust_markers(src);
1540 assert_eq!(markers.len(), 1);
1541 assert_eq!(markers[0].target, SuppressionTarget::Function);
1542 assert_eq!(markers[0].function, None);
1543 }
1544
1545 #[test]
1546 fn collector_recognizes_lizard_dialect() {
1547 let src = "fn f() {\n // #lizard forgives\n}\n";
1548 let markers = rust_markers(src);
1549 assert_eq!(markers.len(), 1);
1550 assert_eq!(markers[0].dialect, SuppressionDialect::Lizard);
1551 assert_eq!(markers[0].function.as_deref(), Some("f"));
1552 }
1553
1554 #[test]
1555 fn collector_markers_sorted_by_line() {
1556 let src = "fn a() {\n // bca: suppress\n}\nfn b() {\n // bca: suppress\n}\n";
1557 let markers = rust_markers(src);
1558 assert_eq!(markers.len(), 2);
1559 assert!(markers[0].line < markers[1].line);
1560 assert_eq!(markers[0].function.as_deref(), Some("a"));
1561 assert_eq!(markers[1].function.as_deref(), Some("b"));
1562 }
1563
1564 #[test]
1565 fn collector_python_hash_marker() {
1566 let src = "def helper():\n # bca: suppress\n pass\n";
1567 let parser = PythonParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.py"), None);
1568 let markers = suppression_markers(&parser);
1569 assert_eq!(markers.len(), 1);
1570 assert_eq!(markers[0].target, SuppressionTarget::Function);
1571 assert_eq!(markers[0].function.as_deref(), Some("helper"));
1572 }
1573
1574 #[test]
1575 fn collector_cpp_attributes_enclosing_function() {
1576 // Cross-language coverage: C++ functions are detected and the
1577 // marker is attributed to the enclosing function.
1578 let src = "int compute(int a) {\n // bca: suppress\n return a;\n}\n";
1579 let parser = CppParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.cpp"), None);
1580 let markers = suppression_markers(&parser);
1581 assert_eq!(markers.len(), 1);
1582 assert_eq!(markers[0].target, SuppressionTarget::Function);
1583 assert_eq!(markers[0].function.as_deref(), Some("compute"));
1584 }
1585
1586 #[test]
1587 fn collector_elixir_requires_code_aware_func_predicate() {
1588 // Elixir is the language whose `Checker::is_func` returns `false`
1589 // unconditionally — it identifies functions only through the
1590 // code-aware `is_func_with_code`. This test fails if the walk
1591 // reverts to plain `is_func` (the enclosing function would then
1592 // resolve to `None`), so it pins the predicate choice in
1593 // `suppression_markers`.
1594 let src =
1595 "defmodule M do\n def parse_long do\n # bca: suppress\n x = 1\n end\nend\n";
1596 let parser = ElixirParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.ex"), None);
1597 let markers = suppression_markers(&parser);
1598 assert_eq!(markers.len(), 1);
1599 assert_eq!(markers[0].target, SuppressionTarget::Function);
1600 assert_eq!(markers[0].function.as_deref(), Some("parse_long"));
1601 }
1602
1603 #[test]
1604 fn collector_empty_source_yields_no_markers() {
1605 assert!(rust_markers("").is_empty());
1606 assert!(rust_markers("fn f() {}\n").is_empty());
1607 }
1608
1609 /// A comment that yields no directive contributes nothing to the
1610 /// audit, and does not stop the walk from collecting the markers
1611 /// around it.
1612 ///
1613 /// Two rejections reach [`marker_at`] and both must be silent here.
1614 /// `parse_marker` yields no directive for an ordinary comment that
1615 /// simply is not a marker, and none for a `bca:` body it cannot
1616 /// parse at all. The audit is a read-only listing of what *is* a
1617 /// marker; the threshold walk is the surface that warns, so dropping
1618 /// these without a diagnostic is the contract, not an oversight.
1619 ///
1620 /// A merely *flawed* metric list is a third case and is deliberately
1621 /// not dropped: since #1168 it yields the directive its recognized
1622 /// names describe, so the audit lists it — an author reading the
1623 /// exemptions report needs to see the suppression that is actually
1624 /// in force.
1625 ///
1626 /// Without this, every comment the collector's tests feed it parses
1627 /// successfully, and the reject arm is never taken.
1628 #[test]
1629 fn collector_skips_comments_that_are_not_valid_markers() {
1630 let src = "// an ordinary comment\n\
1631 fn f() {\n\
1632 \x20 // bca: suppress garbage\n\
1633 \x20 // bca: disable(cognitive)\n\
1634 \x20 // bca: suppress(cognitive)\n\
1635 }\n";
1636 let markers = rust_markers(src);
1637 assert_eq!(
1638 markers.len(),
1639 1,
1640 "only the well-formed marker is collected, got {markers:?}"
1641 );
1642 assert_eq!(markers[0].line, 5);
1643 assert_eq!(markers[0].function.as_deref(), Some("f"));
1644
1645 // And a file of nothing but rejected comments yields nothing at
1646 // all, rather than a marker with a defaulted scope.
1647 assert!(
1648 rust_markers("// bca: disable\n// not a marker at all\n").is_empty(),
1649 "a rejected marker must not be collected with a fallback scope"
1650 );
1651 }
1652
1653 /// A marker carrying a rationale still attaches when the comment is
1654 /// the last thing in the file, with no trailing newline.
1655 ///
1656 /// Per `.claude/rules/testing.md`, both the `check_metrics` shim and
1657 /// the integration suites append a newline to every fixture, so "a
1658 /// node ending at EOF" is unreachable from them —
1659 /// [`crate::test_support::space_verbatim`] analyses the bytes as
1660 /// given. The rationale is what makes this worth pinning: it is the
1661 /// part of the marker adjacent to the missing newline, so a future
1662 /// parser that indexed past the `)` unconditionally would fail here
1663 /// and nowhere else.
1664 #[test]
1665 fn rationale_marker_at_eof_without_trailing_newline() {
1666 let space = crate::test_support::space_verbatim(
1667 crate::LANG::Rust,
1668 b"fn f(a: u8, b: u8) -> u8 { a + b }\n\
1669 // bca: suppress-file(nargs) \xe2\x80\x94 two is plenty",
1670 crate::MetricsOptions::default(),
1671 );
1672 assert!(
1673 space.suppressed.covers(Metric::Nargs),
1674 "file-scoped marker at EOF must attach; got {:?}",
1675 space.suppressed,
1676 );
1677 }
1678
1679 /// CRLF line endings leave a `\r` inside the comment token in most
1680 /// grammars, so it lands in the rationale rather than in the metric
1681 /// list. Pinned because the pre-#1168 parser reached the same answer
1682 /// for the opposite reason: it trimmed the `\r` off a body that had
1683 /// nothing after the `)` at all.
1684 #[test]
1685 fn rationale_marker_survives_crlf_line_endings() {
1686 let space = crate::test_support::space_verbatim(
1687 crate::LANG::Rust,
1688 "fn f(a: u8, b: u8) -> u8 {\r\n\
1689 // bca: suppress(nargs) \u{2014} two is plenty\r\n\
1690 a + b\r\n}\r\n"
1691 .as_bytes(),
1692 crate::MetricsOptions::default(),
1693 );
1694 let f = space
1695 .spaces
1696 .iter()
1697 .find(|s| s.name.as_deref() == Some("f"))
1698 .expect("function space f");
1699 assert!(
1700 f.suppressed.covers(Metric::Nargs),
1701 "CRLF marker must attach; got {:?}",
1702 f.suppressed,
1703 );
1704 }
1705
1706 #[test]
1707 fn collector_lists_a_marker_whose_list_was_partly_unusable() {
1708 // The audit reports the suppression that is *in force*. Since
1709 // #1168 that is the recognized half of a flawed list, so the
1710 // marker must appear — with `cognitive` only, not with a
1711 // defaulted `All` scope, which would misreport it as silencing
1712 // everything.
1713 let src = "fn f() {\n // bca: suppress(cognitive, exit) — state machine\n}\n";
1714 let markers = rust_markers(src);
1715 assert_eq!(markers.len(), 1, "got {markers:?}");
1716 assert!(
1717 matches!(&markers[0].scope, SuppressionScope::Some(m)
1718 if m.iter().copied().eq([Metric::Cognitive])),
1719 "got {:?}",
1720 markers[0].scope,
1721 );
1722 }
1723}