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