use std::collections::BTreeSet;
use std::fmt;
use std::str::FromStr;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricKind {
Cognitive,
Cyclomatic,
Halstead,
Loc,
Mi,
Nargs,
Nom,
Npa,
Npm,
Abc,
Exit,
Wmc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuppressionPolicy {
Honor,
Ignore,
}
impl SuppressionPolicy {
#[must_use]
pub const fn from_no_suppress(no_suppress: bool) -> Self {
if no_suppress {
Self::Ignore
} else {
Self::Honor
}
}
}
impl MetricKind {
#[must_use]
pub fn for_threshold_name(name: &str) -> Option<Self> {
let family = name.split_once('.').map_or(name, |(prefix, _)| prefix);
let canonical = match family {
"nexits" => "exit",
"tokens" => return None,
other => other,
};
Self::from_str(canonical).ok()
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cognitive => "cognitive",
Self::Cyclomatic => "cyclomatic",
Self::Halstead => "halstead",
Self::Loc => "loc",
Self::Mi => "mi",
Self::Nargs => "nargs",
Self::Nom => "nom",
Self::Npa => "npa",
Self::Npm => "npm",
Self::Abc => "abc",
Self::Exit => "exit",
Self::Wmc => "wmc",
}
}
pub const ALL: &'static [Self] = &[
Self::Abc,
Self::Cognitive,
Self::Cyclomatic,
Self::Exit,
Self::Halstead,
Self::Loc,
Self::Mi,
Self::Nargs,
Self::Nom,
Self::Npa,
Self::Npm,
Self::Wmc,
];
}
impl fmt::Display for MetricKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for MetricKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::ALL
.iter()
.copied()
.find(|m| m.as_str() == s)
.ok_or(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "metrics")]
pub enum SuppressionScope {
All,
Some(BTreeSet<MetricKind>),
}
impl Default for SuppressionScope {
fn default() -> Self {
Self::Some(BTreeSet::new())
}
}
impl SuppressionScope {
#[must_use]
pub fn is_all(&self) -> bool {
matches!(self, Self::All)
}
#[must_use]
pub fn is_empty(&self) -> bool {
matches!(self, Self::Some(s) if s.is_empty())
}
#[must_use]
pub fn covers(&self, metric: MetricKind) -> bool {
match self {
Self::All => true,
Self::Some(s) => s.contains(&metric),
}
}
pub(crate) fn merge(&mut self, other: &SuppressionScope) {
match (&mut *self, other) {
(Self::All, _) => {}
(slot, Self::All) => *slot = Self::All,
(Self::Some(a), Self::Some(b)) => a.extend(b.iter().copied()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SuppressionKind {
Function,
File,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SuppressionSource {
Native,
Lizard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Suppression {
pub(crate) kind: SuppressionKind,
pub(crate) scope: SuppressionScope,
pub(crate) source: SuppressionSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SuppressionError {
UnknownVerb(String),
UnknownMetric(String),
MalformedBody(String),
}
impl fmt::Display for SuppressionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownVerb(v) => write!(
f,
"unknown bca directive verb '{v}'; expected `suppress` or `suppress-file`"
),
Self::UnknownMetric(m) => {
let known = MetricKind::ALL
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"unknown metric '{m}' in bca suppression marker; known metrics: {known}"
)
}
Self::MalformedBody(body) => {
write!(f, "malformed bca suppression marker body '{body}'")
}
}
}
}
impl std::error::Error for SuppressionError {}
pub(crate) fn parse_marker(comment_text: &str) -> Result<Option<Suppression>, SuppressionError> {
if !comment_text.contains("bca:") && !comment_text.contains("lizard") {
return Ok(None);
}
let trimmed = strip_block_delims(comment_text.trim()).trim();
let no_opener = trimmed
.trim_start_matches(|c: char| {
c == '/' || c == '*' || c == '!' || c == ';' || c == '-' || c.is_whitespace()
})
.trim_end_matches(|c: char| c == '*' || c == '/' || c.is_whitespace())
.trim();
let lizard_candidate = if no_opener.starts_with("#l") {
no_opener
} else if let Some(rest) = no_opener.strip_prefix('#') {
rest.trim_start()
} else {
no_opener
};
if let Some(s) = parse_lizard(lizard_candidate) {
return Ok(Some(s));
}
let body = no_opener
.trim_start_matches(|c: char| c == '#' || c.is_whitespace())
.trim();
parse_native(body)
}
fn strip_block_delims(s: &str) -> &str {
let s = s.strip_prefix("/*").unwrap_or(s);
s.strip_suffix("*/").unwrap_or(s)
}
fn parse_lizard(trimmed: &str) -> Option<Suppression> {
let s = trimmed.strip_prefix('#')?.trim_start();
let s = s.strip_prefix("lizard")?;
let rest = s.trim();
if rest == "forgives" {
return Some(Suppression {
kind: SuppressionKind::Function,
scope: SuppressionScope::All,
source: SuppressionSource::Lizard,
});
}
if rest == "forgive global" {
return Some(Suppression {
kind: SuppressionKind::File,
scope: SuppressionScope::All,
source: SuppressionSource::Lizard,
});
}
None
}
fn parse_native(body: &str) -> Result<Option<Suppression>, SuppressionError> {
let Some(rest) = body.strip_prefix("bca:") else {
return Ok(None);
};
let rest = rest.trim_start();
if rest.is_empty() {
return Ok(None);
}
let malformed = || SuppressionError::MalformedBody(body.to_owned());
let verb_end = rest
.find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
.unwrap_or(rest.len());
let (verb, after_verb) = rest.split_at(verb_end);
if verb.is_empty() {
return Err(malformed());
}
let kind = match verb {
"suppress" => SuppressionKind::Function,
"suppress-file" => SuppressionKind::File,
other => return Err(SuppressionError::UnknownVerb(other.to_owned())),
};
let after_verb = after_verb.trim_start();
let scope = if after_verb.is_empty() {
SuppressionScope::All
} else if let Some(rest) = after_verb.strip_prefix('(') {
let close = rest.find(')').ok_or_else(malformed)?;
let (inside, trailing) = rest.split_at(close);
if !trailing[1..].trim().is_empty() {
return Err(malformed());
}
parse_metric_list(inside)?
} else {
return Err(malformed());
};
Ok(Some(Suppression {
kind,
scope,
source: SuppressionSource::Native,
}))
}
fn parse_metric_list(inside: &str) -> Result<SuppressionScope, SuppressionError> {
let mut set = BTreeSet::new();
for token in inside.split(',') {
let name = token.trim();
if name.is_empty() {
continue;
}
let metric = MetricKind::from_str(name)
.map_err(|()| SuppressionError::UnknownMetric(name.to_owned()))?;
set.insert(metric);
}
Ok(SuppressionScope::Some(set))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_bare_suppress_covers_all_for_function() {
let s = parse_marker("// bca: suppress").unwrap().unwrap();
assert_eq!(s.kind, SuppressionKind::Function);
assert_eq!(s.source, SuppressionSource::Native);
assert!(matches!(s.scope, SuppressionScope::All));
}
#[test]
fn native_suppress_with_metric_list() {
let s = parse_marker("// bca: suppress(cyclomatic, cognitive)")
.unwrap()
.unwrap();
assert_eq!(s.kind, SuppressionKind::Function);
let SuppressionScope::Some(metrics) = s.scope else {
panic!("expected Some(...)");
};
assert!(metrics.contains(&MetricKind::Cyclomatic));
assert!(metrics.contains(&MetricKind::Cognitive));
assert_eq!(metrics.len(), 2);
}
#[test]
fn native_suppress_file_bare() {
let s = parse_marker("# bca: suppress-file").unwrap().unwrap();
assert_eq!(s.kind, SuppressionKind::File);
assert!(matches!(s.scope, SuppressionScope::All));
}
#[test]
fn native_suppress_file_with_metric_list() {
let s = parse_marker("/* bca: suppress-file(halstead, loc) */")
.unwrap()
.unwrap();
assert_eq!(s.kind, SuppressionKind::File);
let SuppressionScope::Some(metrics) = s.scope else {
panic!("expected Some(...)");
};
assert!(metrics.contains(&MetricKind::Halstead));
assert!(metrics.contains(&MetricKind::Loc));
}
#[test]
fn native_unknown_metric_errors() {
let err = parse_marker("// bca: suppress(no_such_metric)").unwrap_err();
assert!(matches!(err, SuppressionError::UnknownMetric(_)));
let rendered = err.to_string();
assert!(rendered.contains("no_such_metric"));
assert!(rendered.contains("cyclomatic"));
}
#[test]
fn native_unknown_verb_errors() {
let err = parse_marker("// bca: disable").unwrap_err();
assert!(matches!(err, SuppressionError::UnknownVerb(_)));
let rendered = err.to_string();
assert!(
rendered.contains("`suppress`"),
"expected message to name the bare `suppress` verb; got: {rendered}"
);
assert!(
rendered.contains("`suppress-file`"),
"expected message to name the `suppress-file` verb; got: {rendered}"
);
}
#[test]
fn legacy_allow_verb_is_unknown() {
let err = parse_marker("// bca: allow").unwrap_err();
assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
let err = parse_marker("// bca: allow-file").unwrap_err();
assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow-file"));
let err = parse_marker("// bca: allow(cyclomatic)").unwrap_err();
assert!(matches!(err, SuppressionError::UnknownVerb(v) if v == "allow"));
}
#[test]
fn native_malformed_body_errors() {
assert!(matches!(
parse_marker("// bca: suppress(cyclomatic").unwrap_err(),
SuppressionError::MalformedBody(_)
));
assert!(matches!(
parse_marker("// bca: suppress(cyclomatic) junk").unwrap_err(),
SuppressionError::MalformedBody(_)
));
assert!(matches!(
parse_marker("// bca: suppress garbage").unwrap_err(),
SuppressionError::MalformedBody(_)
));
}
#[test]
fn native_bare_colon_is_not_a_marker() {
assert!(parse_marker("// bca:").unwrap().is_none());
}
#[test]
fn empty_metric_list_is_noop_not_error() {
let s = parse_marker("// bca: suppress()").unwrap().unwrap();
assert!(s.scope.is_empty());
assert!(!s.scope.covers(MetricKind::Cyclomatic));
}
#[test]
fn lizard_function_marker() {
let s = parse_marker("// #lizard forgives").unwrap().unwrap();
assert_eq!(s.kind, SuppressionKind::Function);
assert_eq!(s.source, SuppressionSource::Lizard);
assert!(matches!(s.scope, SuppressionScope::All));
}
#[test]
fn lizard_file_marker() {
let s = parse_marker("# #lizard forgive global").unwrap().unwrap();
assert_eq!(s.kind, SuppressionKind::File);
assert_eq!(s.source, SuppressionSource::Lizard);
}
#[test]
fn lizard_unknown_phrase_is_not_a_marker() {
assert!(parse_marker("// #lizard skip").unwrap().is_none());
}
#[test]
fn plain_comment_is_not_a_marker() {
assert!(parse_marker("// just a comment").unwrap().is_none());
assert!(parse_marker("/* TODO: fix later */").unwrap().is_none());
}
#[test]
fn fast_bail_skips_sigil_free_comments() {
assert!(
parse_marker("// Copyright (c) 2026 Some Corp.")
.unwrap()
.is_none()
);
assert!(
parse_marker("/* SPDX-License-Identifier: MIT */")
.unwrap()
.is_none()
);
assert!(
parse_marker("// authors: jane lizard, john doe")
.unwrap()
.is_none()
);
}
#[test]
fn marker_grammar_is_case_sensitive() {
assert!(parse_marker("// Bca: suppress").unwrap().is_none());
assert!(parse_marker("/* BCA: suppress */").unwrap().is_none());
assert!(parse_marker("# #Lizard forgives").unwrap().is_none());
assert!(parse_marker("// #Lizard forgives").unwrap().is_none());
}
#[test]
fn metric_kind_round_trips() {
for &m in MetricKind::ALL {
assert_eq!(MetricKind::from_str(m.as_str()), Ok(m));
}
}
#[test]
fn metric_kind_all_is_alphabetical() {
assert!(
MetricKind::ALL.is_sorted_by_key(|m| m.as_str()),
"MetricKind::ALL must stay sorted so the error-hint ordering is stable; got {:?}",
MetricKind::ALL
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>(),
);
}
#[test]
fn scope_merge_all_absorbs() {
let mut a = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
a.merge(&SuppressionScope::All);
assert!(a.is_all());
let mut b = SuppressionScope::All;
b.merge(&SuppressionScope::Some(BTreeSet::from([MetricKind::Loc])));
assert!(b.is_all());
}
#[test]
fn scope_merge_some_unions() {
let mut a = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
a.merge(&SuppressionScope::Some(BTreeSet::from([
MetricKind::Cognitive,
])));
assert!(a.covers(MetricKind::Loc));
assert!(a.covers(MetricKind::Cognitive));
assert!(!a.covers(MetricKind::Cyclomatic));
}
#[test]
fn scope_covers_respects_all_vs_some() {
assert!(SuppressionScope::All.covers(MetricKind::Cyclomatic));
let some = SuppressionScope::Some(BTreeSet::from([MetricKind::Loc]));
assert!(some.covers(MetricKind::Loc));
assert!(!some.covers(MetricKind::Cyclomatic));
}
#[test]
fn for_threshold_name_maps_dotted_subnames_to_families() {
assert_eq!(
MetricKind::for_threshold_name("cyclomatic"),
Some(MetricKind::Cyclomatic)
);
assert_eq!(
MetricKind::for_threshold_name("cyclomatic.modified"),
Some(MetricKind::Cyclomatic)
);
assert_eq!(
MetricKind::for_threshold_name("halstead.volume"),
Some(MetricKind::Halstead)
);
assert_eq!(
MetricKind::for_threshold_name("loc.lloc"),
Some(MetricKind::Loc)
);
}
#[test]
fn for_threshold_name_aliases_nexits_to_exit() {
assert_eq!(
MetricKind::for_threshold_name("nexits"),
Some(MetricKind::Exit)
);
}
#[test]
fn for_threshold_name_returns_none_for_unknown() {
assert_eq!(MetricKind::for_threshold_name("tokens"), None);
assert_eq!(MetricKind::for_threshold_name("no_such_metric"), None);
}
#[test]
fn default_scope_is_empty() {
let d = SuppressionScope::default();
assert!(d.is_empty());
assert!(!d.is_all());
}
#[test]
fn inner_doc_comments_recognized() {
let line = parse_marker("//! bca: suppress").unwrap().unwrap();
assert_eq!(line.kind, SuppressionKind::Function);
assert!(matches!(line.scope, SuppressionScope::All));
let block = parse_marker("/*! bca: suppress */").unwrap().unwrap();
assert_eq!(block.kind, SuppressionKind::Function);
assert!(matches!(block.scope, SuppressionScope::All));
}
}