use std::collections::BTreeSet;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::checker::Checker;
use crate::getter::Getter;
use crate::metric_set::Metric;
use crate::node::Node;
use crate::traits::ParserTrait;
#[must_use]
pub fn threshold_metric_for_name(name: &str) -> Option<Metric> {
let family = name.split_once('.').map_or(name, |(prefix, _)| prefix);
if family == "tokens" {
return None;
}
family.parse().ok()
}
#[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
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "metrics")]
pub enum SuppressionScope {
All,
Some(BTreeSet<Metric>),
}
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: Metric) -> bool {
match self {
Self::All => true,
Self::Some(s) => s.contains(&metric),
}
}
pub 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),
NonSuppressibleMetric(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 mut names: Vec<String> = Metric::suppressible()
.map(|metric| metric.to_string())
.collect();
names.sort_unstable();
let known = names.join(", ");
write!(
f,
"unknown metric '{m}' in bca suppression marker; known metrics: {known}"
)
}
Self::NonSuppressibleMetric(m) => {
write!(f, "metric '{m}' has no threshold and cannot be suppressed")
}
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: Metric = name
.parse()
.map_err(|_| SuppressionError::UnknownMetric(name.to_owned()))?;
if metric == Metric::Tokens {
return Err(SuppressionError::NonSuppressibleMetric(name.to_owned()));
}
set.insert(metric);
}
Ok(SuppressionScope::Some(set))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SuppressionTarget {
Function,
File,
}
impl From<SuppressionKind> for SuppressionTarget {
fn from(kind: SuppressionKind) -> Self {
match kind {
SuppressionKind::Function => Self::Function,
SuppressionKind::File => Self::File,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SuppressionDialect {
Native,
Lizard,
}
impl From<SuppressionSource> for SuppressionDialect {
fn from(source: SuppressionSource) -> Self {
match source {
SuppressionSource::Native => Self::Native,
SuppressionSource::Lizard => Self::Lizard,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SuppressionMarker {
pub line: usize,
pub target: SuppressionTarget,
pub scope: SuppressionScope,
pub dialect: SuppressionDialect,
pub function: Option<String>,
}
#[must_use]
pub(crate) fn suppression_markers<T: ParserTrait>(parser: &T) -> Vec<SuppressionMarker> {
let code = parser.code();
let mut markers = Vec::new();
let mut stack: Vec<(Node<'_>, Option<&str>)> = vec![(parser.root(), None)];
while let Some((node, enclosing)) = stack.pop() {
if T::Checker::is_comment(&node)
&& let Some(text) = node.utf8_text(code)
&& let Ok(Some(suppression)) = parse_marker(text)
{
let function = match suppression.kind {
SuppressionKind::Function => enclosing.map(str::to_owned),
SuppressionKind::File => None,
};
markers.push(SuppressionMarker {
line: node.start_row() + 1,
target: suppression.kind.into(),
scope: suppression.scope,
dialect: suppression.source.into(),
function,
});
}
let child_enclosing = if T::Checker::is_func_with_code(&node, code) {
T::Getter::get_func_name(&node, code).or(enclosing)
} else {
enclosing
};
for child in node.children() {
stack.push((child, child_enclosing));
}
}
markers.sort_by_key(|m| m.line);
markers
}
#[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(&Metric::Cyclomatic));
assert!(metrics.contains(&Metric::Cognitive));
assert_eq!(metrics.len(), 2);
}
#[test]
fn native_mixed_valid_and_unknown_metric_voids_whole_marker() {
let err = parse_marker("// bca: suppress(cyclomatic, no_such_metric)").unwrap_err();
assert!(
matches!(&err, SuppressionError::UnknownMetric(name) if name == "no_such_metric"),
"mixed valid+unknown marker must error on the unknown token, \
voiding the whole list; got {err:?}",
);
}
#[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(&Metric::Halstead));
assert!(metrics.contains(&Metric::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"));
assert!(
!rendered.contains("tokens"),
"hint must omit the non-suppressible `tokens`; got: {rendered}",
);
let mut expected: Vec<String> = Metric::suppressible()
.map(|metric| metric.to_string())
.collect();
expected.sort_unstable();
assert!(
rendered.ends_with(&format!("known metrics: {}", expected.join(", "))),
"hint must list exactly the suppressible metrics from \
`Metric::suppressible()`, alphabetised; got: {rendered}",
);
}
#[test]
fn native_tokens_is_not_suppressible() {
let err = parse_marker("// bca: suppress(tokens)").unwrap_err();
assert!(
matches!(&err, SuppressionError::NonSuppressibleMetric(m) if m == "tokens"),
"expected NonSuppressibleMetric(\"tokens\"); got: {err:?}",
);
let rendered = err.to_string();
assert!(rendered.contains("tokens"));
assert!(
rendered.contains("no threshold"),
"message must explain why tokens cannot be suppressed; got: {rendered}",
);
}
#[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(Metric::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 scope_merge_all_absorbs() {
let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
a.merge(&SuppressionScope::All);
assert!(a.is_all());
let mut b = SuppressionScope::All;
b.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Loc])));
assert!(b.is_all());
}
#[test]
fn scope_merge_some_unions() {
let mut a = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
a.merge(&SuppressionScope::Some(BTreeSet::from([Metric::Cognitive])));
assert!(a.covers(Metric::Loc));
assert!(a.covers(Metric::Cognitive));
assert!(!a.covers(Metric::Cyclomatic));
}
#[test]
fn scope_covers_respects_all_vs_some() {
assert!(SuppressionScope::All.covers(Metric::Cyclomatic));
let some = SuppressionScope::Some(BTreeSet::from([Metric::Loc]));
assert!(some.covers(Metric::Loc));
assert!(!some.covers(Metric::Cyclomatic));
}
#[test]
fn scope_serialization_uses_canonical_names_and_stable_order() {
let scope = SuppressionScope::Some(BTreeSet::from([
Metric::Wmc,
Metric::Nexits,
Metric::Nargs,
Metric::Cognitive,
]));
let json = serde_json::to_string(&scope).unwrap();
assert_eq!(
json,
r#"{"kind":"some","metrics":["cognitive","nargs","nexits","wmc"]}"#,
);
let back: SuppressionScope = serde_json::from_str(&json).unwrap();
assert_eq!(back, scope);
}
#[test]
fn for_threshold_name_maps_dotted_subnames_to_families() {
assert_eq!(
threshold_metric_for_name("cyclomatic"),
Some(Metric::Cyclomatic)
);
assert_eq!(
threshold_metric_for_name("cyclomatic.modified"),
Some(Metric::Cyclomatic)
);
assert_eq!(
threshold_metric_for_name("halstead.volume"),
Some(Metric::Halstead)
);
assert_eq!(threshold_metric_for_name("loc.lloc"), Some(Metric::Loc));
}
#[test]
fn for_threshold_name_resolves_nexits_canonically() {
assert_eq!(threshold_metric_for_name("nexits"), Some(Metric::Nexits));
}
#[test]
fn for_threshold_name_returns_none_for_unknown() {
assert_eq!(threshold_metric_for_name("tokens"), None);
assert_eq!(threshold_metric_for_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));
}
use crate::{CppParser, ElixirParser, PythonParser, RustParser};
use std::path::PathBuf;
fn rust_markers(src: &str) -> Vec<SuppressionMarker> {
let parser = RustParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.rs"), None);
suppression_markers(&parser)
}
#[test]
fn collector_function_scoped_native_marker_attributes_enclosing_fn() {
let src = "fn do_thing() {\n // bca: suppress\n let x = 1;\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
let m = &markers[0];
assert_eq!(m.line, 2);
assert_eq!(m.target, SuppressionTarget::Function);
assert_eq!(m.dialect, SuppressionDialect::Native);
assert!(matches!(m.scope, SuppressionScope::All));
assert_eq!(m.function.as_deref(), Some("do_thing"));
}
#[test]
fn collector_metric_list_scope_is_preserved() {
let src = "fn f() {\n // bca: suppress(cyclomatic, cognitive)\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
let SuppressionScope::Some(metrics) = &markers[0].scope else {
panic!("expected an explicit metric set");
};
assert!(metrics.contains(&Metric::Cyclomatic));
assert!(metrics.contains(&Metric::Cognitive));
assert_eq!(metrics.len(), 2);
}
#[test]
fn collector_file_scoped_marker_has_no_enclosing_fn() {
let src = "fn f() {\n // bca: suppress-file\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].target, SuppressionTarget::File);
assert_eq!(markers[0].function, None);
}
#[test]
fn collector_nested_fn_attributes_innermost() {
let src = "fn outer() {\n fn inner() {\n // bca: suppress\n }\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].function.as_deref(), Some("inner"));
}
#[test]
fn collector_marker_outside_any_fn_has_no_enclosing_fn() {
let src = "// bca: suppress\nfn f() {}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].target, SuppressionTarget::Function);
assert_eq!(markers[0].function, None);
}
#[test]
fn collector_recognizes_lizard_dialect() {
let src = "fn f() {\n // #lizard forgives\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].dialect, SuppressionDialect::Lizard);
assert_eq!(markers[0].function.as_deref(), Some("f"));
}
#[test]
fn collector_markers_sorted_by_line() {
let src = "fn a() {\n // bca: suppress\n}\nfn b() {\n // bca: suppress\n}\n";
let markers = rust_markers(src);
assert_eq!(markers.len(), 2);
assert!(markers[0].line < markers[1].line);
assert_eq!(markers[0].function.as_deref(), Some("a"));
assert_eq!(markers[1].function.as_deref(), Some("b"));
}
#[test]
fn collector_python_hash_marker() {
let src = "def helper():\n # bca: suppress\n pass\n";
let parser = PythonParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.py"), None);
let markers = suppression_markers(&parser);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].target, SuppressionTarget::Function);
assert_eq!(markers[0].function.as_deref(), Some("helper"));
}
#[test]
fn collector_cpp_attributes_enclosing_function() {
let src = "int compute(int a) {\n // bca: suppress\n return a;\n}\n";
let parser = CppParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.cpp"), None);
let markers = suppression_markers(&parser);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].target, SuppressionTarget::Function);
assert_eq!(markers[0].function.as_deref(), Some("compute"));
}
#[test]
fn collector_elixir_requires_code_aware_func_predicate() {
let src =
"defmodule M do\n def parse_long do\n # bca: suppress\n x = 1\n end\nend\n";
let parser = ElixirParser::new(src.as_bytes().to_vec(), &PathBuf::from("t.ex"), None);
let markers = suppression_markers(&parser);
assert_eq!(markers.len(), 1);
assert_eq!(markers[0].target, SuppressionTarget::Function);
assert_eq!(markers[0].function.as_deref(), Some("parse_long"));
}
#[test]
fn collector_empty_source_yields_no_markers() {
assert!(rust_markers("").is_empty());
assert!(rust_markers("fn f() {}\n").is_empty());
}
}