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::analyze`] / [`crate::Ast::metrics`] and attached to the
21//! matching [`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;
28
29use serde::{Deserialize, Serialize};
30
31use crate::checker::Checker;
32use crate::getter::Getter;
33use crate::metric_set::Metric;
34use crate::node::Node;
35use crate::traits::ParserTrait;
36
37/// Resolve a sub-metric threshold name (e.g. `cyclomatic.modified`,
38/// `halstead.volume`, `loc.lloc`) to its parent [`Metric`].
39///
40/// The threshold engine uses dotted forms to address individual
41/// sub-metrics, but suppression markers only know about the top-level
42/// metric family — silencing `halstead` silences all of
43/// `halstead.volume`, `halstead.effort`, etc. This translation happens
44/// here so the threshold-check loop can ask one question ("does this
45/// scope cover this metric family?") instead of special-casing each
46/// dotted name.
47///
48/// Returns `None` for `tokens`: it has no configurable threshold and is
49/// deliberately absent from the suppressible vocabulary
50/// ([`Metric::suppressible`]), so a marker can never silence it.
51#[must_use]
52pub fn threshold_metric_for_name(name: &str) -> Option<Metric> {
53 // Strip the dotted sub-metric suffix if present. `name` like
54 // `halstead.volume` becomes `halstead`; `nom` stays as-is.
55 let family = name.split_once('.').map_or(name, |(prefix, _)| prefix);
56 // `tokens` is in the threshold registry but is not suppressible, so
57 // it maps to no metric family. Every other name parses via the
58 // canonical `Metric::from_str` — `nexits` is the spelling on both
59 // sides now, so no alias bridge is needed (the pre-unification
60 // `nexits -> exit` mapping retired with `MetricKind` in #555).
61 if family == "tokens" {
62 return None;
63 }
64 family.parse().ok()
65}
66
67/// Whether downstream consumers (threshold checking, audit logging)
68/// should honor parsed suppression markers.
69///
70/// `Honor` is the default behaviour for `bca check` runs; `Ignore`
71/// powers the `--no-suppress` CLI flag so CI auditors can see the raw,
72/// un-silenced offender list without editing source files.
73// Deliberately exhaustive: a total binary toggle (honor markers vs
74// ignore them). There is no third state to add, so `#[non_exhaustive]`
75// would only force callers into a pointless wildcard arm.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SuppressionPolicy {
78 /// Skip violations whose metric is covered by an applicable marker.
79 Honor,
80 /// Emit every violation regardless of markers.
81 Ignore,
82}
83
84impl SuppressionPolicy {
85 /// Construct from a boolean `no_suppress` flag, as parsed from the
86 /// CLI. `true` means "ignore markers" (`--no-suppress` set);
87 /// `false` means "honor markers" (the default).
88 #[must_use]
89 pub const fn from_no_suppress(no_suppress: bool) -> Self {
90 if no_suppress {
91 Self::Ignore
92 } else {
93 Self::Honor
94 }
95 }
96}
97
98/// Which metrics a suppression marker covers.
99///
100/// `All` means the marker omits an explicit metric list and therefore
101/// silences every threshold for the enclosing scope. `Some` carries
102/// the explicit list parsed from `bca: suppress(a, b, c)`; an empty set
103/// means the marker effectively suppresses nothing (only possible via
104/// an empty `()` list, which is treated as a no-op rather than an
105/// error).
106// Deliberately exhaustive: a total model of "everything (`All`) vs an
107// explicit set (`Some`)". Any new coverage shape is expressible as a
108// `Some(set)` rather than a new variant, so the two cases are closed.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case", tag = "kind", content = "metrics")]
111pub enum SuppressionScope {
112 /// Suppress every metric.
113 All,
114 /// Suppress only the listed metrics.
115 Some(BTreeSet<Metric>),
116}
117
118impl Default for SuppressionScope {
119 /// The default scope suppresses nothing — empty `Some` so newly
120 /// constructed `FuncSpace`s carry "no suppressions" without having
121 /// to allocate.
122 fn default() -> Self {
123 Self::Some(BTreeSet::new())
124 }
125}
126
127impl SuppressionScope {
128 /// True when the scope suppresses every metric.
129 #[must_use]
130 pub fn is_all(&self) -> bool {
131 matches!(self, Self::All)
132 }
133
134 /// True when the scope suppresses nothing — used by serde to elide
135 /// the field from JSON output when no markers fired.
136 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 matches!(self, Self::Some(s) if s.is_empty())
139 }
140
141 /// True when this scope suppresses `metric`.
142 #[must_use]
143 pub fn covers(&self, metric: Metric) -> bool {
144 match self {
145 Self::All => true,
146 Self::Some(s) => s.contains(&metric),
147 }
148 }
149
150 /// Merge `other` into `self`. `All` absorbs everything; otherwise
151 /// the two sets union. Used when multiple markers stack on the
152 /// same function or file, and by report consumers to fold a file's
153 /// `suppress-file` scope into each function's own scope (issue #501).
154 pub fn merge(&mut self, other: &SuppressionScope) {
155 match (&mut *self, other) {
156 (Self::All, _) => {}
157 (slot, Self::All) => *slot = Self::All,
158 (Self::Some(a), Self::Some(b)) => a.extend(b.iter().copied()),
159 }
160 }
161}
162
163/// Whether a marker applies to the enclosing function or to the
164/// whole file.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub(crate) enum SuppressionKind {
167 /// Suppress thresholds for the function the comment lives in.
168 Function,
169 /// Suppress thresholds for the whole file.
170 File,
171}
172
173/// Which dialect surfaced this suppression — useful for the audit log
174/// so projects can migrate Lizard-style markers over time.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub(crate) enum SuppressionSource {
178 /// Native `bca:` marker.
179 Native,
180 /// Lizard compatibility marker.
181 Lizard,
182}
183
184/// A single suppression directive parsed from a comment.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub(crate) struct Suppression {
187 /// Function- vs file-scoped.
188 pub(crate) kind: SuppressionKind,
189 /// Which metrics the marker covers.
190 pub(crate) scope: SuppressionScope,
191 /// Native vs Lizard dialect.
192 pub(crate) source: SuppressionSource,
193}
194
195/// Error returned when a marker is recognized as a `bca:` directive but
196/// the body is malformed (unknown verb, malformed list, unknown metric
197/// identifier). Lizard-style markers never error: anything that does
198/// not match the exact `#lizard forgives` / `#lizard forgive global`
199/// shapes simply parses as "not a marker".
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub(crate) enum SuppressionError {
202 /// `bca:` directive used an unrecognized verb (anything other than
203 /// `suppress` / `suppress-file`).
204 UnknownVerb(String),
205 /// `bca: suppress(...)` listed an identifier that is not a known
206 /// metric name.
207 UnknownMetric(String),
208 /// `bca: suppress(...)` named a real metric that has no configurable
209 /// threshold and therefore cannot be suppressed (currently only
210 /// `tokens`). Distinct from [`Self::UnknownMetric`] so the author
211 /// learns the name parsed but is simply not silenceable.
212 NonSuppressibleMetric(String),
213 /// `bca: suppress(...)` body could not be tokenized (e.g. unbalanced
214 /// parentheses, stray characters).
215 MalformedBody(String),
216}
217
218impl fmt::Display for SuppressionError {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 // Single-quote delimiters keep the rendered identifier readable
221 // without the `{:?}`-style escaping that would otherwise wrap
222 // user-supplied verb / metric tokens in literal backslashes.
223 match self {
224 Self::UnknownVerb(v) => write!(
225 f,
226 "unknown bca directive verb '{v}'; expected `suppress` or `suppress-file`"
227 ),
228 Self::UnknownMetric(m) => {
229 // The hint lists the suppressible metrics, derived from
230 // `Metric::suppressible()` (the single source of truth for
231 // the suppressible vocabulary — it already excludes the
232 // non-suppressible `tokens`) rather than re-deriving from
233 // `Metric::NAMES` with a hardcoded filter. `suppressible()`
234 // iterates declaration order; we sort so the hint stays
235 // alphabetised and thus stable across releases.
236 let mut names: Vec<String> = Metric::suppressible()
237 .map(|metric| metric.to_string())
238 .collect();
239 names.sort_unstable();
240 let known = names.join(", ");
241 write!(
242 f,
243 "unknown metric '{m}' in bca suppression marker; known metrics: {known}"
244 )
245 }
246 Self::NonSuppressibleMetric(m) => {
247 write!(f, "metric '{m}' has no threshold and cannot be suppressed")
248 }
249 Self::MalformedBody(body) => {
250 write!(f, "malformed bca suppression marker body '{body}'")
251 }
252 }
253 }
254}
255
256impl std::error::Error for SuppressionError {}
257
258/// Parse a single comment's text and try to extract a suppression
259/// directive. Returns:
260///
261/// - `Ok(None)` when the comment carries no marker (the common case).
262/// - `Ok(Some(s))` when a marker was successfully parsed.
263/// - `Err(e)` only for *native* markers whose body is malformed —
264/// Lizard-style markers never error.
265///
266/// The input is the raw comment text **including** the comment-syntax
267/// delimiters (e.g. `// bca: suppress`, `# bca: suppress`, `/* bca: suppress */`).
268/// The following leading delimiter characters are stripped before
269/// matching so per-language wrappers do not have to normalise:
270/// `/`, `*`, `!`, `#`, `;`, `-`, and ASCII whitespace. The `!` entry
271/// covers Rust inner doc comments (`//!`, `/*!`); the `;` and `-`
272/// entries cover Lisp / SQL / Lua line-comment shapes.
273pub(crate) fn parse_marker(comment_text: &str) -> Result<Option<Suppression>, SuppressionError> {
274 // Fast-bail: this function runs on every comment node. Most
275 // comments are license headers, doc comments, or TODO notes that
276 // contain neither sigil. `str::contains` is SIMD-accelerated and
277 // avoids the trim/strip chain below for the dominant case.
278 if !comment_text.contains("bca:") && !comment_text.contains("lizard") {
279 return Ok(None);
280 }
281
282 // Strip a `/*` opener and a `*/` closer if present so we don't
283 // confuse block-comment delimiters with marker prefixes.
284 let trimmed = strip_block_delims(comment_text.trim()).trim();
285
286 // Strip language-level comment openers *other than* `#`. We can't
287 // strip `#` here because Lizard's marker shape (`#lizard
288 // forgives`) needs the `#` to remain. In C++ `// #lizard ...`
289 // the `// ` must come off first so Lizard parsing sees `#lizard
290 // ...`. In Python `# #lizard ...` (the outer `#` is the language
291 // comment opener) tree-sitter delivers the raw `# #lizard ...`
292 // text — so the inner body still starts with `#`, which Lizard
293 // parsing wants. In both cases the no-`#` trim leaves the
294 // `#lizard` token intact.
295 // `!` is included so inner doc comments — `//! bca: suppress` and
296 // `/*! bca: suppress */` — strip down to the same body as their
297 // outer counterparts. Without this, the leading `!` would survive
298 // the strip and break the `bca:` prefix match.
299 let no_opener = trimmed
300 .trim_start_matches(|c: char| {
301 c == '/' || c == '*' || c == '!' || c == ';' || c == '-' || c.is_whitespace()
302 })
303 .trim_end_matches(|c: char| c == '*' || c == '/' || c.is_whitespace())
304 .trim();
305
306 // Python-style: tree-sitter delivers `# bca: suppress` with the
307 // leading `#` intact. Lizard expects `#lizard ...` — a literal
308 // `#` *followed by* `lizard`, no space. If the first `#` is the
309 // language's comment opener, strip exactly one `#` and any
310 // whitespace before retrying Lizard. The Python `# #lizard ...`
311 // shape is then also covered because two `#`s round-trip
312 // through one strip + one Lizard `#` prefix.
313 //
314 // Match `#l` only — Lizard's own scanner is case-sensitive
315 // (`parse_lizard` does `strip_prefix("lizard")`), so accepting
316 // `#L` here would just defer a failure to `parse_lizard`. Keeping
317 // the discriminator lowercase-only also matches the fast-bail
318 // above (`contains("lizard")`).
319 let lizard_candidate = if no_opener.starts_with("#l") {
320 // Already in `#lizard ...` shape after only block-delim
321 // stripping — typical for C++ where `// #lizard ...` has
322 // had `// ` removed above.
323 no_opener
324 } else if let Some(rest) = no_opener.strip_prefix('#') {
325 // Python/Bash style: `# #lizard ...` or `# bca: ...`. Drop
326 // the language comment opener; Lizard parsing only fires
327 // when what remains starts with another `#lizard`.
328 rest.trim_start()
329 } else {
330 no_opener
331 };
332
333 if let Some(s) = parse_lizard(lizard_candidate) {
334 return Ok(Some(s));
335 }
336
337 // For native parsing, strip the same `#` opener so `# bca: suppress`
338 // matches. The remaining body is then checked for the `bca:`
339 // prefix.
340 let body = no_opener
341 .trim_start_matches(|c: char| c == '#' || c.is_whitespace())
342 .trim();
343
344 parse_native(body)
345}
346
347fn strip_block_delims(s: &str) -> &str {
348 let s = s.strip_prefix("/*").unwrap_or(s);
349 s.strip_suffix("*/").unwrap_or(s)
350}
351
352fn parse_lizard(trimmed: &str) -> Option<Suppression> {
353 // `#lizard forgives` — function-scoped, all metrics.
354 // `#lizard forgive global` — file-scoped, all metrics.
355 //
356 // Lizard's own scanner tolerates a single space after `#` and
357 // around the verb, but is otherwise exact. We mirror that by
358 // trimming the ends and matching the verb phrase verbatim.
359 let s = trimmed.strip_prefix('#')?.trim_start();
360 let s = s.strip_prefix("lizard")?;
361 let rest = s.trim();
362
363 if rest == "forgives" {
364 return Some(Suppression {
365 kind: SuppressionKind::Function,
366 scope: SuppressionScope::All,
367 source: SuppressionSource::Lizard,
368 });
369 }
370 if rest == "forgive global" {
371 return Some(Suppression {
372 kind: SuppressionKind::File,
373 scope: SuppressionScope::All,
374 source: SuppressionSource::Lizard,
375 });
376 }
377 None
378}
379
380fn parse_native(body: &str) -> Result<Option<Suppression>, SuppressionError> {
381 // The native dialect is `bca:` followed by a verb (`suppress` or
382 // `suppress-file`), optionally followed by `(metric, metric, ...)`.
383 let Some(rest) = body.strip_prefix("bca:") else {
384 return Ok(None);
385 };
386 let rest = rest.trim_start();
387 if rest.is_empty() {
388 // A bare `bca:` with nothing after it isn't useful; treat as
389 // not-a-marker rather than an error so the user can write
390 // documentation that mentions the namespace without firing.
391 return Ok(None);
392 }
393
394 let malformed = || SuppressionError::MalformedBody(body.to_owned());
395
396 // Split into verb + parenthesised body. We accept whitespace
397 // between the verb and `(`. The verb is the longest prefix of
398 // ASCII letters and `-`.
399 let verb_end = rest
400 .find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
401 .unwrap_or(rest.len());
402 let (verb, after_verb) = rest.split_at(verb_end);
403 if verb.is_empty() {
404 return Err(malformed());
405 }
406
407 let kind = match verb {
408 "suppress" => SuppressionKind::Function,
409 "suppress-file" => SuppressionKind::File,
410 other => return Err(SuppressionError::UnknownVerb(other.to_owned())),
411 };
412
413 let after_verb = after_verb.trim_start();
414 let scope = if after_verb.is_empty() {
415 SuppressionScope::All
416 } else if let Some(rest) = after_verb.strip_prefix('(') {
417 let close = rest.find(')').ok_or_else(malformed)?;
418 let (inside, trailing) = rest.split_at(close);
419 // After the `)` only whitespace (and `*/` already trimmed by
420 // caller) is allowed. Anything else is a malformed marker:
421 // reject so `bca: suppress(loc) garbage` doesn't silently succeed.
422 if !trailing[1..].trim().is_empty() {
423 return Err(malformed());
424 }
425 parse_metric_list(inside)?
426 } else {
427 // Trailing text after the verb that isn't `(...)`: reject.
428 return Err(malformed());
429 };
430
431 Ok(Some(Suppression {
432 kind,
433 scope,
434 source: SuppressionSource::Native,
435 }))
436}
437
438fn parse_metric_list(inside: &str) -> Result<SuppressionScope, SuppressionError> {
439 let mut set = BTreeSet::new();
440 for token in inside.split(',') {
441 let name = token.trim();
442 if name.is_empty() {
443 // Empty `()` or trailing commas: skip. An empty list
444 // suppresses nothing — equivalent to the marker being
445 // absent. We accept rather than error so authors can
446 // comment out parts of a list during editing.
447 continue;
448 }
449 // Parse through the canonical `Metric` vocabulary (the same one
450 // selection uses) so suppression and selection never drift. A
451 // typo surfaces the offending token via `ParseMetricError`
452 // (#554). `tokens` parses fine but has no threshold, so reject
453 // it with a distinct, actionable error rather than silently
454 // accepting a no-op suppression.
455 let metric: Metric = name
456 .parse()
457 .map_err(|_| SuppressionError::UnknownMetric(name.to_owned()))?;
458 if metric == Metric::Tokens {
459 return Err(SuppressionError::NonSuppressibleMetric(name.to_owned()));
460 }
461 set.insert(metric);
462 }
463 Ok(SuppressionScope::Some(set))
464}
465
466/// Whether an audited suppression marker applies to its enclosing
467/// function or to the whole file.
468///
469/// The public mirror of the crate-internal `SuppressionKind`; exposed
470/// on [`SuppressionMarker`] so the `bca exemptions` audit (issue #386)
471/// can report marker scope without leaking the internal type.
472// Deliberately exhaustive: function- and file-scope are the only two
473// suppression granularities the marker grammar models. A new granularity
474// would be a grammar-level change, planned deliberately, not a silent
475// additive variant.
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
477#[serde(rename_all = "snake_case")]
478pub enum SuppressionTarget {
479 /// Marker silences thresholds for its enclosing function only.
480 Function,
481 /// Marker silences thresholds for the whole file.
482 File,
483}
484
485impl From<SuppressionKind> for SuppressionTarget {
486 fn from(kind: SuppressionKind) -> Self {
487 match kind {
488 SuppressionKind::Function => Self::Function,
489 SuppressionKind::File => Self::File,
490 }
491 }
492}
493
494/// Which marker dialect produced a suppression.
495///
496/// The public mirror of the crate-internal `SuppressionSource`;
497/// exposed on [`SuppressionMarker`] so an audit can flag Lizard-style
498/// markers that projects may want to migrate to the native `bca:`
499/// dialect over time.
500// New tool dialects beyond Native and Lizard are plausible (other
501// linters with their own forgive-marker syntax), so this carries
502// `#[non_exhaustive]` to keep such additions additive rather than a 2.0
503// break. The CLI `marker_label` tuple match has a `_ =>` arm for the
504// not-yet-known dialects.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
506#[serde(rename_all = "snake_case")]
507#[non_exhaustive]
508pub enum SuppressionDialect {
509 /// Native `bca:` marker.
510 Native,
511 /// Lizard compatibility marker (`#lizard forgives`).
512 Lizard,
513}
514
515impl From<SuppressionSource> for SuppressionDialect {
516 fn from(source: SuppressionSource) -> Self {
517 match source {
518 SuppressionSource::Native => Self::Native,
519 SuppressionSource::Lizard => Self::Lizard,
520 }
521 }
522}
523
524/// A single in-source suppression marker located within a file, carrying
525/// the context needed to audit it.
526///
527/// Produced by [`crate::Ast::suppressions`] for the `bca exemptions`
528/// report (issue #386). Unlike the
529/// merged [`crate::FuncSpace::suppressed`] scope — which records only
530/// *what* a function ends up suppressing — this records each marker's
531/// own location, dialect, and the enclosing function it was written in,
532/// so reviewers can see every silencer in the tree, not just its net
533/// effect.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
535pub struct SuppressionMarker {
536 /// 1-based line of the comment that carries the marker.
537 pub line: usize,
538 /// Whether the marker is function- or file-scoped.
539 pub target: SuppressionTarget,
540 /// Which metrics the marker covers (`all` or a named set).
541 pub scope: SuppressionScope,
542 /// Native vs Lizard dialect.
543 pub dialect: SuppressionDialect,
544 /// Enclosing function name for a function-scoped marker, if the
545 /// marker sits inside a function body. `None` for file-scoped
546 /// markers (whole-file by definition) and for function-scoped
547 /// markers written outside any function (which silence nothing — a
548 /// dead marker worth surfacing in an audit).
549 pub function: Option<String>,
550}
551
552/// Collect every in-source suppression marker in a parsed file, with the
553/// location and enclosing-function context the `bca exemptions` audit
554/// reports (issue #386).
555///
556/// The walk mirrors the comment-scanning step in
557/// [`crate::analyze`] / [`crate::Ast::metrics`]: it visits comment nodes,
558/// parses each through [`parse_marker`], and records the successes.
559/// Malformed native markers are skipped silently here — the audit is a
560/// read-only listing of what *is* a marker, and the threshold walk is
561/// the surface that already warns on malformed bodies.
562///
563/// Enclosing-function attribution tracks the syntactically nearest
564/// function ancestor during a depth-first walk, matching the body-
565/// containment rule the real suppression logic uses (issue #289) rather
566/// than line-range guessing. Markers are returned sorted by line.
567///
568/// Crate-internal walk core reached through the
569/// [`crate::Ast::suppressions`] seam.
570#[must_use]
571pub(crate) fn suppression_markers<T: ParserTrait>(parser: &T) -> Vec<SuppressionMarker> {
572 let code = parser.code();
573 let mut markers = Vec::new();
574 // Explicit-stack DFS (not recursion) so a pathologically deep AST
575 // cannot overflow the call stack. Each frame carries the nearest
576 // enclosing function name, borrowed from `code`, so child nodes
577 // inherit it without re-deriving.
578 let mut stack: Vec<(Node<'_>, Option<&str>)> = vec![(parser.root(), None)];
579 while let Some((node, enclosing)) = stack.pop() {
580 if T::Checker::is_comment(&node)
581 && let Some(text) = node.utf8_text(code)
582 && let Ok(Some(suppression)) = parse_marker(text)
583 {
584 // File-scoped markers are whole-file by definition, so the
585 // enclosing function is irrelevant; report `None` to avoid a
586 // misleading "inside fn X" attribution.
587 let function = match suppression.kind {
588 SuppressionKind::Function => enclosing.map(str::to_owned),
589 SuppressionKind::File => None,
590 };
591 markers.push(SuppressionMarker {
592 line: node.start_row() + 1,
593 target: suppression.kind.into(),
594 scope: suppression.scope,
595 dialect: suppression.source.into(),
596 function,
597 });
598 }
599 // `is_func_with_code` rather than `is_func`: C/C++ identify
600 // functions only via the code-aware predicate, and the default
601 // impl delegates to `is_func` for every other language.
602 let child_enclosing = if T::Checker::is_func_with_code(&node, code) {
603 T::Getter::get_func_name(&node, code).or(enclosing)
604 } else {
605 enclosing
606 };
607 for child in node.children() {
608 stack.push((child, child_enclosing));
609 }
610 }
611 markers.sort_by_key(|m| m.line);
612 markers
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn native_bare_suppress_covers_all_for_function() {
621 let s = parse_marker("// bca: suppress").unwrap().unwrap();
622 assert_eq!(s.kind, SuppressionKind::Function);
623 assert_eq!(s.source, SuppressionSource::Native);
624 assert!(matches!(s.scope, SuppressionScope::All));
625 }
626
627 #[test]
628 fn native_suppress_with_metric_list() {
629 let s = parse_marker("// bca: suppress(cyclomatic, cognitive)")
630 .unwrap()
631 .unwrap();
632 assert_eq!(s.kind, SuppressionKind::Function);
633 let SuppressionScope::Some(metrics) = s.scope else {
634 panic!("expected Some(...)");
635 };
636 assert!(metrics.contains(&Metric::Cyclomatic));
637 assert!(metrics.contains(&Metric::Cognitive));
638 assert_eq!(metrics.len(), 2);
639 }
640
641 #[test]
642 fn native_mixed_valid_and_unknown_metric_voids_whole_marker() {
643 // The void-on-typo contract: a marker listing one valid metric
644 // beside an unknown one must reject the ENTIRE list, not silently
645 // honor the valid part. Otherwise a misspelled metric would still
646 // suppress the correctly-spelled one beside it — the most
647 // dangerous failure mode, since it widens scope on a typo. Every
648 // other test feeds a marker whose only metric is unknown, where
649 // "void whole marker" and "skip the unknown token" are
650 // indistinguishable; only a mixed list separates them. Swapping
651 // the `?` in `parse_metric_list` for `continue` makes this parse
652 // to `Some({Cyclomatic})` and trips the assertion (#948).
653 let err = parse_marker("// bca: suppress(cyclomatic, no_such_metric)").unwrap_err();
654 assert!(
655 matches!(&err, SuppressionError::UnknownMetric(name) if name == "no_such_metric"),
656 "mixed valid+unknown marker must error on the unknown token, \
657 voiding the whole list; got {err:?}",
658 );
659 }
660
661 #[test]
662 fn native_suppress_file_bare() {
663 let s = parse_marker("# bca: suppress-file").unwrap().unwrap();
664 assert_eq!(s.kind, SuppressionKind::File);
665 assert!(matches!(s.scope, SuppressionScope::All));
666 }
667
668 #[test]
669 fn native_suppress_file_with_metric_list() {
670 let s = parse_marker("/* bca: suppress-file(halstead, loc) */")
671 .unwrap()
672 .unwrap();
673 assert_eq!(s.kind, SuppressionKind::File);
674 let SuppressionScope::Some(metrics) = s.scope else {
675 panic!("expected Some(...)");
676 };
677 assert!(metrics.contains(&Metric::Halstead));
678 assert!(metrics.contains(&Metric::Loc));
679 }
680
681 #[test]
682 fn native_unknown_metric_errors() {
683 let err = parse_marker("// bca: suppress(no_such_metric)").unwrap_err();
684 assert!(matches!(err, SuppressionError::UnknownMetric(_)));
685 // The error must mention what was unknown so authors can
686 // diagnose typos without reading our source. This is the #554
687 // acceptance: the offending token is surfaced (it now flows out
688 // of `Metric::from_str`'s `ParseMetricError`, not a `()` error).
689 let rendered = err.to_string();
690 assert!(rendered.contains("no_such_metric"));
691 // And it must list the known metrics so a fix is one
692 // copy-paste away.
693 assert!(rendered.contains("cyclomatic"));
694 // The non-suppressible `tokens` must NOT appear in the hint —
695 // suggesting it would be misleading.
696 assert!(
697 !rendered.contains("tokens"),
698 "hint must omit the non-suppressible `tokens`; got: {rendered}",
699 );
700 // The hint must be derived from `Metric::suppressible()` (the
701 // documented single source of truth), not a re-derived list.
702 // Guards #805: every suppressible metric appears, alphabetised.
703 let mut expected: Vec<String> = Metric::suppressible()
704 .map(|metric| metric.to_string())
705 .collect();
706 expected.sort_unstable();
707 assert!(
708 rendered.ends_with(&format!("known metrics: {}", expected.join(", "))),
709 "hint must list exactly the suppressible metrics from \
710 `Metric::suppressible()`, alphabetised; got: {rendered}",
711 );
712 }
713
714 #[test]
715 fn native_tokens_is_not_suppressible() {
716 // `tokens` parses as a real `Metric` but has no threshold, so a
717 // marker naming it is rejected with a distinct, actionable error
718 // rather than silently accepted as a no-op suppression.
719 let err = parse_marker("// bca: suppress(tokens)").unwrap_err();
720 assert!(
721 matches!(&err, SuppressionError::NonSuppressibleMetric(m) if m == "tokens"),
722 "expected NonSuppressibleMetric(\"tokens\"); got: {err:?}",
723 );
724 let rendered = err.to_string();
725 assert!(rendered.contains("tokens"));
726 assert!(
727 rendered.contains("no threshold"),
728 "message must explain why tokens cannot be suppressed; got: {rendered}",
729 );
730 }
731
732 #[test]
733 fn native_unknown_verb_errors() {
734 let err = parse_marker("// bca: disable").unwrap_err();
735 assert!(matches!(err, SuppressionError::UnknownVerb(_)));
736 // The error message must guide the author toward the correct
737 // verbs without making them grep our source. Anchor each verb
738 // with its surrounding backticks so the bare `suppress` check
739 // can't be silently satisfied by the substring inside
740 // `suppress-file` — a future message that drops the bare verb
741 // and keeps only the compound one would otherwise pass this
742 // assertion.
743 let rendered = err.to_string();
744 assert!(
745 rendered.contains("`suppress`"),
746 "expected message to name the bare `suppress` verb; got: {rendered}"
747 );
748 assert!(
749 rendered.contains("`suppress-file`"),
750 "expected message to name the `suppress-file` verb; got: {rendered}"
751 );
752 }
753
754 /// Locks the hard rename in issue #263: the previous spelling
755 /// `// bca: allow` (and `// bca: allow-file`) must no longer be
756 /// recognized. They now fall through to `UnknownVerb`, the same
757 /// path as any other typo. A future revert that re-adds the old
758 /// verb to the match would silently re-enable old-style markers
759 /// in shipped source; this test catches that.
760 #[test]
761 fn legacy_allow_verb_is_unknown() {
762 let err = parse_marker("// bca: allow").unwrap_err();
763 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
764 let err = parse_marker("// bca: allow-file").unwrap_err();
765 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow-file"));
766 let err = parse_marker("// bca: allow(cyclomatic)").unwrap_err();
767 assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
768 }
769
770 #[test]
771 fn native_malformed_body_errors() {
772 // Unbalanced paren.
773 assert!(matches!(
774 parse_marker("// bca: suppress(cyclomatic").unwrap_err(),
775 SuppressionError::MalformedBody(_)
776 ));
777 // Trailing garbage after the metric list.
778 assert!(matches!(
779 parse_marker("// bca: suppress(cyclomatic) junk").unwrap_err(),
780 SuppressionError::MalformedBody(_)
781 ));
782 // Verb followed by something other than `(...)`.
783 assert!(matches!(
784 parse_marker("// bca: suppress garbage").unwrap_err(),
785 SuppressionError::MalformedBody(_)
786 ));
787 }
788
789 #[test]
790 fn native_bare_colon_is_not_a_marker() {
791 // `bca:` with nothing after it is not a marker; we want to
792 // allow documentation comments to mention the namespace.
793 assert!(parse_marker("// bca:").unwrap().is_none());
794 }
795
796 #[test]
797 fn empty_metric_list_is_noop_not_error() {
798 let s = parse_marker("// bca: suppress()").unwrap().unwrap();
799 assert!(s.scope.is_empty());
800 assert!(!s.scope.covers(Metric::Cyclomatic));
801 }
802
803 #[test]
804 fn lizard_function_marker() {
805 let s = parse_marker("// #lizard forgives").unwrap().unwrap();
806 assert_eq!(s.kind, SuppressionKind::Function);
807 assert_eq!(s.source, SuppressionSource::Lizard);
808 assert!(matches!(s.scope, SuppressionScope::All));
809 }
810
811 #[test]
812 fn lizard_file_marker() {
813 let s = parse_marker("# #lizard forgive global").unwrap().unwrap();
814 assert_eq!(s.kind, SuppressionKind::File);
815 assert_eq!(s.source, SuppressionSource::Lizard);
816 }
817
818 #[test]
819 fn lizard_unknown_phrase_is_not_a_marker() {
820 // Per the issue's narrow compat surface: `#lizard skip` is not
821 // a recognized Lizard directive, so we treat it as no marker
822 // rather than erroring or silently suppressing.
823 assert!(parse_marker("// #lizard skip").unwrap().is_none());
824 }
825
826 #[test]
827 fn plain_comment_is_not_a_marker() {
828 assert!(parse_marker("// just a comment").unwrap().is_none());
829 assert!(parse_marker("/* TODO: fix later */").unwrap().is_none());
830 }
831
832 /// Locks the fast-bail contract in `parse_marker`: comments that
833 /// contain neither `bca:` nor `lizard` must short-circuit to
834 /// `Ok(None)`. A future change broadening the substring check
835 /// (case-insensitive, etc.) would silently shift parsing semantics
836 /// for comments that mention `Bca:` or `Lizard` in prose; this
837 /// test catches that.
838 #[test]
839 fn fast_bail_skips_sigil_free_comments() {
840 // Long, sigil-free comments that should never trigger.
841 assert!(
842 parse_marker("// Copyright (c) 2026 Some Corp.")
843 .unwrap()
844 .is_none()
845 );
846 assert!(
847 parse_marker("/* SPDX-License-Identifier: MIT */")
848 .unwrap()
849 .is_none()
850 );
851 // Substring-mention-but-not-a-marker: contains "lizard" in
852 // prose but is not a Lizard directive. Slow path must still
853 // return Ok(None).
854 assert!(
855 parse_marker("// authors: jane lizard, john doe")
856 .unwrap()
857 .is_none()
858 );
859 }
860
861 /// Locks the case sensitivity of both dialects: `Bca:` and
862 /// `#Lizard` must NOT be recognized. Both the fast-bail and the
863 /// underlying parsers are lowercase-only by design; this test
864 /// pins that contract.
865 #[test]
866 fn marker_grammar_is_case_sensitive() {
867 // Uppercase B in `Bca:` is not a native marker.
868 assert!(parse_marker("// Bca: suppress").unwrap().is_none());
869 assert!(parse_marker("/* BCA: suppress */").unwrap().is_none());
870 // Uppercase L in `#Lizard` is not a Lizard marker. The
871 // fast-bail rejects it (no lowercase "lizard" substring) and
872 // the slow path would also reject it via `strip_prefix("lizard")`.
873 assert!(parse_marker("# #Lizard forgives").unwrap().is_none());
874 assert!(parse_marker("// #Lizard forgives").unwrap().is_none());
875 }
876
877 #[test]
878 fn scope_merge_all_absorbs() {
879 let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
880 a.merge(&SuppressionScope::All);
881 assert!(a.is_all());
882
883 let mut b = SuppressionScope::All;
884 b.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Loc])));
885 assert!(b.is_all());
886 }
887
888 #[test]
889 fn scope_merge_some_unions() {
890 let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
891 a.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Cognitive])));
892 assert!(a.covers(Metric::Loc));
893 assert!(a.covers(Metric::Cognitive));
894 assert!(!a.covers(Metric::Cyclomatic));
895 }
896
897 #[test]
898 fn scope_covers_respects_all_vs_some() {
899 assert!(SuppressionScope::All.covers(Metric::Cyclomatic));
900 let some = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
901 assert!(some.covers(Metric::Loc));
902 assert!(!some.covers(Metric::Cyclomatic));
903 }
904
905 #[test]
906 fn scope_serialization_uses_canonical_names_and_stable_order() {
907 // The serialized `Some` scope must (a) spell metrics with their
908 // canonical names — `nexits`, not `n_exits` or the legacy `exit`
909 // — and (b) iterate in deterministic `Ord` (declaration) order so
910 // snapshots are stable. Insert in scrambled order to prove the
911 // ordering comes from `BTreeSet<Metric>`, not insertion order.
912 let scope = SuppressionScope::Some(BTreeSet::from([
913 Metric::Wmc,
914 Metric::Nexits,
915 Metric::Nargs,
916 Metric::Cognitive,
917 ]));
918 let json = serde_json::to_string(&scope).unwrap();
919 assert_eq!(
920 json,
921 r#"{"kind":"some","metrics":["cognitive","nargs","nexits","wmc"]}"#,
922 );
923 // Round-trips back to the same scope.
924 let back: SuppressionScope = serde_json::from_str(&json).unwrap();
925 assert_eq!(back, scope);
926 }
927
928 #[test]
929 fn for_threshold_name_maps_dotted_subnames_to_families() {
930 // Cyclomatic.modified and cyclomatic both fall under
931 // Metric::Cyclomatic — silencing `cyclomatic` covers the
932 // modified variant too. Same for halstead.* and loc.*.
933 assert_eq!(
934 threshold_metric_for_name("cyclomatic"),
935 Some(Metric::Cyclomatic)
936 );
937 assert_eq!(
938 threshold_metric_for_name("cyclomatic.modified"),
939 Some(Metric::Cyclomatic)
940 );
941 assert_eq!(
942 threshold_metric_for_name("halstead.volume"),
943 Some(Metric::Halstead)
944 );
945 assert_eq!(threshold_metric_for_name("loc.lloc"), Some(Metric::Loc));
946 }
947
948 #[test]
949 fn for_threshold_name_resolves_nexits_canonically() {
950 // Post-#555 the suppression vocabulary uses the same canonical
951 // `nexits` spelling as the threshold engine — no `exit` alias
952 // bridge. `bca: suppress(nexits)` silences a `nexits` threshold
953 // violation directly.
954 assert_eq!(threshold_metric_for_name("nexits"), Some(Metric::Nexits));
955 }
956
957 #[test]
958 fn for_threshold_name_returns_none_for_unknown() {
959 // `tokens` is in the threshold registry but is non-suppressible
960 // (no configurable threshold). Treat as "no metric family" so a
961 // marker can't silence the threshold; this mirrors the parse-side
962 // rejection of `bca: suppress(tokens)`.
963 assert_eq!(threshold_metric_for_name("tokens"), None);
964 assert_eq!(threshold_metric_for_name("no_such_metric"), None);
965 }
966
967 #[test]
968 fn default_scope_is_empty() {
969 let d = SuppressionScope::default();
970 assert!(d.is_empty());
971 assert!(!d.is_all());
972 }
973
974 #[test]
975 fn inner_doc_comments_recognized() {
976 // Rust inner doc comments (`//!`, `/*!`) are the same shape as
977 // their outer counterparts (`///`, `/**`) modulo the `!` byte.
978 // Without `!` in the leading-strip set the marker prefix `bca:`
979 // would not match. Both line- and block-comment variants must
980 // round-trip the same way.
981 let line = parse_marker("//! bca: suppress").unwrap().unwrap();
982 assert_eq!(line.kind, SuppressionKind::Function);
983 assert!(matches!(line.scope, SuppressionScope::All));
984
985 let block = parse_marker("/*! bca: suppress */").unwrap().unwrap();
986 assert_eq!(block.kind, SuppressionKind::Function);
987 assert!(matches!(block.scope, SuppressionScope::All));
988 }
989
990 use crate::{CppParser, ElixirParser, PythonParser, RustParser};
991 use std::path::PathBuf;
992
993 /// Collect markers from a Rust snippet via the public collector.
994 fn rust_markers(src: &str) -> Vec<SuppressionMarker> {
995 let parser = RustParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.rs"), None);
996 suppression_markers(&parser)
997 }
998
999 #[test]
1000 fn collector_function_scoped_native_marker_attributes_enclosing_fn() {
1001 // The marker sits inside `do_thing`'s body, so the audit must
1002 // attribute it to that function — the body-containment rule, not
1003 // a line-range guess.
1004 let src = "fn do_thing() {\n // bca: suppress\n let x = 1;\n}\n";
1005 let markers = rust_markers(src);
1006 assert_eq!(markers.len(), 1);
1007 let m = &markers[0];
1008 assert_eq!(m.line, 2);
1009 assert_eq!(m.target, SuppressionTarget::Function);
1010 assert_eq!(m.dialect, SuppressionDialect::Native);
1011 assert!(matches!(m.scope, SuppressionScope::All));
1012 assert_eq!(m.function.as_deref(), Some("do_thing"));
1013 }
1014
1015 #[test]
1016 fn collector_metric_list_scope_is_preserved() {
1017 let src = "fn f() {\n // bca: suppress(cyclomatic, cognitive)\n}\n";
1018 let markers = rust_markers(src);
1019 assert_eq!(markers.len(), 1);
1020 let SuppressionScope::Some(metrics) = &markers[0].scope else {
1021 panic!("expected an explicit metric set");
1022 };
1023 assert!(metrics.contains(&Metric::Cyclomatic));
1024 assert!(metrics.contains(&Metric::Cognitive));
1025 assert_eq!(metrics.len(), 2);
1026 }
1027
1028 #[test]
1029 fn collector_file_scoped_marker_has_no_enclosing_fn() {
1030 // A `suppress-file` marker is whole-file by definition; the
1031 // enclosing function must be elided even though it is written
1032 // inside a function body.
1033 let src = "fn f() {\n // bca: suppress-file\n}\n";
1034 let markers = rust_markers(src);
1035 assert_eq!(markers.len(), 1);
1036 assert_eq!(markers[0].target, SuppressionTarget::File);
1037 assert_eq!(markers[0].function, None);
1038 }
1039
1040 #[test]
1041 fn collector_nested_fn_attributes_innermost() {
1042 // The marker is inside the inner function; attribution must pick
1043 // the syntactically nearest enclosing function, not the outer.
1044 let src = "fn outer() {\n fn inner() {\n // bca: suppress\n }\n}\n";
1045 let markers = rust_markers(src);
1046 assert_eq!(markers.len(), 1);
1047 assert_eq!(markers[0].function.as_deref(), Some("inner"));
1048 }
1049
1050 #[test]
1051 fn collector_marker_outside_any_fn_has_no_enclosing_fn() {
1052 // A function-scoped marker with no enclosing function silences
1053 // nothing; the audit still lists it (a dead marker) with no
1054 // function attribution.
1055 let src = "// bca: suppress\nfn f() {}\n";
1056 let markers = rust_markers(src);
1057 assert_eq!(markers.len(), 1);
1058 assert_eq!(markers[0].target, SuppressionTarget::Function);
1059 assert_eq!(markers[0].function, None);
1060 }
1061
1062 #[test]
1063 fn collector_recognizes_lizard_dialect() {
1064 let src = "fn f() {\n // #lizard forgives\n}\n";
1065 let markers = rust_markers(src);
1066 assert_eq!(markers.len(), 1);
1067 assert_eq!(markers[0].dialect, SuppressionDialect::Lizard);
1068 assert_eq!(markers[0].function.as_deref(), Some("f"));
1069 }
1070
1071 #[test]
1072 fn collector_markers_sorted_by_line() {
1073 let src = "fn a() {\n // bca: suppress\n}\nfn b() {\n // bca: suppress\n}\n";
1074 let markers = rust_markers(src);
1075 assert_eq!(markers.len(), 2);
1076 assert!(markers[0].line < markers[1].line);
1077 assert_eq!(markers[0].function.as_deref(), Some("a"));
1078 assert_eq!(markers[1].function.as_deref(), Some("b"));
1079 }
1080
1081 #[test]
1082 fn collector_python_hash_marker() {
1083 let src = "def helper():\n # bca: suppress\n pass\n";
1084 let parser = PythonParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.py"), None);
1085 let markers = suppression_markers(&parser);
1086 assert_eq!(markers.len(), 1);
1087 assert_eq!(markers[0].target, SuppressionTarget::Function);
1088 assert_eq!(markers[0].function.as_deref(), Some("helper"));
1089 }
1090
1091 #[test]
1092 fn collector_cpp_attributes_enclosing_function() {
1093 // Cross-language coverage: C++ functions are detected and the
1094 // marker is attributed to the enclosing function.
1095 let src = "int compute(int a) {\n // bca: suppress\n return a;\n}\n";
1096 let parser = CppParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.cpp"), None);
1097 let markers = suppression_markers(&parser);
1098 assert_eq!(markers.len(), 1);
1099 assert_eq!(markers[0].target, SuppressionTarget::Function);
1100 assert_eq!(markers[0].function.as_deref(), Some("compute"));
1101 }
1102
1103 #[test]
1104 fn collector_elixir_requires_code_aware_func_predicate() {
1105 // Elixir is the language whose `Checker::is_func` returns `false`
1106 // unconditionally — it identifies functions only through the
1107 // code-aware `is_func_with_code`. This test fails if the walk
1108 // reverts to plain `is_func` (the enclosing function would then
1109 // resolve to `None`), so it pins the predicate choice in
1110 // `suppression_markers`.
1111 let src =
1112 "defmodule M do\n def parse_long do\n # bca: suppress\n x = 1\n end\nend\n";
1113 let parser = ElixirParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.ex"), None);
1114 let markers = suppression_markers(&parser);
1115 assert_eq!(markers.len(), 1);
1116 assert_eq!(markers[0].target, SuppressionTarget::Function);
1117 assert_eq!(markers[0].function.as_deref(), Some("parse_long"));
1118 }
1119
1120 #[test]
1121 fn collector_empty_source_yields_no_markers() {
1122 assert!(rust_markers("").is_empty());
1123 assert!(rust_markers("fn f() {}\n").is_empty());
1124 }
1125}