Skip to main content

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}