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//! - **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}