use regex::Regex;
use serde::Serialize;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SilencerKind {
Allow,
DeadCode,
Nosemgrep,
TsIgnore,
TsExpectError,
TsNocheck,
EslintDisable,
Noqa,
TypeIgnore,
PylintDisable,
MypyIgnore,
Shellcheck,
Unsafe,
UnsafeEnvVar,
Ignore,
}
impl SilencerKind {
pub fn label(self) -> &'static str {
match self {
SilencerKind::Allow => "allow",
SilencerKind::DeadCode => "dead-code",
SilencerKind::Nosemgrep => "nosemgrep",
SilencerKind::TsIgnore => "ts-ignore",
SilencerKind::TsExpectError => "ts-expect-error",
SilencerKind::TsNocheck => "ts-nocheck",
SilencerKind::EslintDisable => "eslint-disable",
SilencerKind::Noqa => "noqa",
SilencerKind::TypeIgnore => "type-ignore",
SilencerKind::PylintDisable => "pylint-disable",
SilencerKind::MypyIgnore => "mypy-ignore",
SilencerKind::Shellcheck => "shellcheck",
SilencerKind::Unsafe => "unsafe",
SilencerKind::UnsafeEnvVar => "unsafe-env-var",
SilencerKind::Ignore => "ignore",
}
}
pub fn all() -> &'static [SilencerKind] {
&[
SilencerKind::Nosemgrep,
SilencerKind::DeadCode,
SilencerKind::Allow,
SilencerKind::Ignore,
SilencerKind::Unsafe,
SilencerKind::UnsafeEnvVar,
SilencerKind::TsIgnore,
SilencerKind::TsExpectError,
SilencerKind::TsNocheck,
SilencerKind::EslintDisable,
SilencerKind::Noqa,
SilencerKind::TypeIgnore,
SilencerKind::PylintDisable,
SilencerKind::MypyIgnore,
SilencerKind::Shellcheck,
]
}
pub fn from_filter(token: &str) -> Option<Self> {
match token.trim().to_lowercase().as_str() {
"allow" => Some(SilencerKind::Allow),
"dead-code" | "dead_code" | "deadcode" => Some(SilencerKind::DeadCode),
"nosemgrep" => Some(SilencerKind::Nosemgrep),
"ts-ignore" | "tsignore" => Some(SilencerKind::TsIgnore),
"ts-expect-error" | "tsexpecterror" | "ts-expect" => Some(SilencerKind::TsExpectError),
"ts-nocheck" | "tsnocheck" => Some(SilencerKind::TsNocheck),
"eslint-disable" | "eslint" | "eslintdisable" => Some(SilencerKind::EslintDisable),
"noqa" => Some(SilencerKind::Noqa),
"type-ignore" | "type_ignore" | "typeignore" => Some(SilencerKind::TypeIgnore),
"pylint-disable" | "pylint" | "pylintdisable" => Some(SilencerKind::PylintDisable),
"mypy-ignore" | "mypy" | "mypyignore" => Some(SilencerKind::MypyIgnore),
"shellcheck" | "shell" => Some(SilencerKind::Shellcheck),
"unsafe" => Some(SilencerKind::Unsafe),
"unsafe-env-var" | "unsafe_env_var" | "unsafe-env" | "envvar" => {
Some(SilencerKind::UnsafeEnvVar)
}
"ignore" => Some(SilencerKind::Ignore),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SilencerMatch {
pub kind: SilencerKind,
pub file: String,
pub line: usize,
pub snippet: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SilencerInventory {
pub matches: Vec<SilencerMatch>,
pub counts: BTreeMap<String, usize>,
pub files_per_kind: BTreeMap<String, usize>,
pub total: usize,
pub total_files: usize,
}
impl SilencerInventory {
fn from_matches(matches: Vec<SilencerMatch>) -> Self {
let mut counts: HashMap<SilencerKind, usize> = HashMap::new();
let mut files: HashMap<SilencerKind, HashSet<String>> = HashMap::new();
let mut all_files: HashSet<String> = HashSet::new();
for m in &matches {
*counts.entry(m.kind).or_insert(0) += 1;
files.entry(m.kind).or_default().insert(m.file.clone());
all_files.insert(m.file.clone());
}
let counts_out: BTreeMap<String, usize> = counts
.into_iter()
.map(|(k, v)| (k.label().to_string(), v))
.collect();
let files_out: BTreeMap<String, usize> = files
.into_iter()
.map(|(k, v)| (k.label().to_string(), v.len()))
.collect();
let total = matches.len();
let total_files = all_files.len();
Self {
matches,
counts: counts_out,
files_per_kind: files_out,
total,
total_files,
}
}
}
static RUST_ALLOW_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\[(allow|expect|deny|warn)\(([^)]+)\)\]").unwrap());
static RUST_IGNORE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*#\[ignore").unwrap());
static RUST_UNSAFE_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\bunsafe\s*\{").unwrap());
static RUST_ENV_VAR_CALL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:std::)?env::(?:set_var|remove_var)\s*\(").unwrap());
static NOSEMGREP_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?://|#)\s*nosemgrep(?:\s*:\s*([\w\-./:]+))?\s*$").unwrap());
static TS_IGNORE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@ts-ignore\b").unwrap());
static TS_EXPECT_ERR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"@ts-expect-error\b").unwrap());
static TS_NOCHECK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@ts-nocheck\b").unwrap());
static ESLINT_DISABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"eslint-disable(?:-next-line|-line)?(?:\s+([\w\-,/@\s]+?))?(?:\s*\*/|$|\s*//)")
.unwrap()
});
static PY_NOQA_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\s*noqa(?:\s*:\s*([\w,\s]+))?").unwrap());
static PY_TYPE_IGNORE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\s*type\s*:\s*ignore(?:\[([\w\-,\s]+)\])?").unwrap());
static PY_PYLINT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\s*pylint\s*:\s*disable\s*=\s*([\w\-,\s]+)").unwrap());
static PY_MYPY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\s*mypy\s*:\s*([\w\-=,\s]+)").unwrap());
static SHELLCHECK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#\s*shellcheck\s+disable\s*=\s*([\w,\s]+)").unwrap());
pub fn scan_repo(
root: &Path,
filter: &HashSet<SilencerKind>,
extra_ignore_globs: &[String],
) -> Vec<SilencerMatch> {
use walkdir::WalkDir;
let mut matches: Vec<SilencerMatch> = Vec::new();
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let ignore_matcher = build_ignore_matcher(extra_ignore_globs);
let walker = WalkDir::new(&canonical_root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
if entry.depth() == 0 {
return true;
}
if matches!(
name.as_ref(),
"target"
| "node_modules"
| ".git"
| "dist"
| "build"
| ".venv"
| "venv"
| "__pycache__"
| ".cargo"
| ".npm"
) {
return false;
}
if name.starts_with('.') && name != ".github" {
return false;
}
true
});
for dent in walker.flatten() {
let path = dent.path();
if !path.is_file() {
continue;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
if !is_supported_ext(ext) {
continue;
}
let rel = path
.strip_prefix(&canonical_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
if ignore_matcher.is_match(&rel) {
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
scan_file(&rel, &content, ext, filter, &mut matches);
}
matches.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.line.cmp(&b.line))
.then(a.kind.cmp(&b.kind))
});
matches
}
pub fn inventory(
root: &Path,
filter: &HashSet<SilencerKind>,
extra_ignore_globs: &[String],
) -> SilencerInventory {
SilencerInventory::from_matches(scan_repo(root, filter, extra_ignore_globs))
}
fn is_supported_ext(ext: &str) -> bool {
matches!(
ext,
"rs" | "ts"
| "tsx"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "mts"
| "cts"
| "svelte"
| "astro"
| "vue"
| "py"
| "pyi"
| "sh"
| "bash"
| "zsh"
| "ksh"
)
}
fn want(filter: &HashSet<SilencerKind>, kind: SilencerKind) -> bool {
filter.is_empty() || filter.contains(&kind)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum RustCarry {
Code,
Str,
RawStr(usize),
BlockComment(u32),
}
fn rust_scan_line(line: &str, start: RustCarry, until: usize) -> (RustCarry, bool) {
let bytes = line.as_bytes();
let len = bytes.len();
let mut state = start;
let mut i = 0usize;
let mut taken = false;
let mut in_code = true;
while i < len {
if !taken && i >= until {
in_code = matches!(state, RustCarry::Code);
taken = true;
}
match state {
RustCarry::Code => {
let b = bytes[i];
if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
if !taken && until >= i {
in_code = false;
}
return (RustCarry::Code, in_code);
}
if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
state = RustCarry::BlockComment(1);
i += 2;
continue;
}
if let Some((hashes, skip)) = raw_string_open(bytes, i) {
state = RustCarry::RawStr(hashes);
i += skip;
continue;
}
if b == b'"' {
state = RustCarry::Str;
i += 1;
continue;
}
if b == b'\'' {
i += char_literal_len(bytes, i);
continue;
}
i += 1;
}
RustCarry::Str => {
let b = bytes[i];
if b == b'\\' {
i += 2; continue;
}
if b == b'"' {
state = RustCarry::Code;
}
i += 1;
}
RustCarry::RawStr(hashes) => {
if bytes[i] == b'"' && raw_string_close(bytes, i + 1, hashes) {
state = RustCarry::Code;
i += 1 + hashes;
continue;
}
i += 1;
}
RustCarry::BlockComment(depth) => {
if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
state = RustCarry::BlockComment(depth + 1);
i += 2;
continue;
}
if bytes[i] == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
state = if depth <= 1 {
RustCarry::Code
} else {
RustCarry::BlockComment(depth - 1)
};
i += 2;
continue;
}
i += 1;
}
}
}
if !taken {
in_code = matches!(state, RustCarry::Code);
}
(state, in_code)
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn raw_string_open(bytes: &[u8], i: usize) -> Option<(usize, usize)> {
let len = bytes.len();
if i > 0 && is_ident_byte(bytes[i - 1]) {
return None;
}
let mut j = i;
if j < len && bytes[j] == b'b' {
j += 1;
}
if j >= len || bytes[j] != b'r' {
return None;
}
j += 1;
let hash_start = j;
while j < len && bytes[j] == b'#' {
j += 1;
}
if j < len && bytes[j] == b'"' {
Some((j - hash_start, (j - i) + 1))
} else {
None
}
}
fn raw_string_close(bytes: &[u8], j: usize, hashes: usize) -> bool {
if j + hashes > bytes.len() {
return false;
}
bytes[j..j + hashes].iter().all(|&b| b == b'#')
}
fn char_literal_len(bytes: &[u8], i: usize) -> usize {
let len = bytes.len();
if i + 1 < len && bytes[i + 1] == b'\\' {
let mut j = i + 2;
while j < len && j <= i + 5 {
if bytes[j] == b'\'' {
return j - i + 1;
}
j += 1;
}
return 1; }
if i + 2 < len && bytes[i + 2] == b'\'' && bytes[i + 1] != b'\'' {
return 3;
}
1
}
fn rust_line_carry_states(lines: &[&str]) -> Vec<RustCarry> {
let mut states = Vec::with_capacity(lines.len());
let mut carry = RustCarry::Code;
for line in lines {
states.push(carry);
carry = rust_scan_line(line, carry, usize::MAX).0;
}
states
}
fn rust_position_in_code(line: &str, start: RustCarry, pos: usize) -> bool {
rust_scan_line(line, start, pos).1
}
fn scan_file(
rel_path: &str,
content: &str,
ext: &str,
filter: &HashSet<SilencerKind>,
out: &mut Vec<SilencerMatch>,
) {
let lines: Vec<&str> = content.lines().collect();
let rust_carry: Vec<RustCarry> = if ext == "rs" {
rust_line_carry_states(&lines)
} else {
Vec::new()
};
for (idx, line) in lines.iter().enumerate() {
let line_num = idx + 1;
let snippet = line.trim().to_string();
match ext {
"rs" => scan_rust_line(
LineCtx {
file: rel_path,
line,
line_num,
snippet: &snippet,
lines: &lines,
idx,
line_start: rust_carry[idx],
},
filter,
out,
),
"ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "mts" | "cts" | "svelte" | "astro"
| "vue" => {
scan_js_ts_line(rel_path, line, line_num, &snippet, filter, out);
scan_nosemgrep_line(rel_path, line, line_num, &snippet, filter, out);
}
"py" | "pyi" => {
scan_python_line(rel_path, line, line_num, &snippet, filter, out);
scan_nosemgrep_line(rel_path, line, line_num, &snippet, filter, out);
}
"sh" | "bash" | "zsh" | "ksh" => {
scan_shell_line(rel_path, line, line_num, &snippet, filter, out);
scan_nosemgrep_line(rel_path, line, line_num, &snippet, filter, out);
}
_ => {}
}
}
}
struct LineCtx<'a> {
file: &'a str,
line: &'a str,
line_num: usize,
snippet: &'a str,
lines: &'a [&'a str],
idx: usize,
line_start: RustCarry,
}
fn scan_rust_line(ctx: LineCtx<'_>, filter: &HashSet<SilencerKind>, out: &mut Vec<SilencerMatch>) {
let LineCtx {
file,
line,
line_num,
snippet,
lines,
idx,
line_start,
} = ctx;
let trimmed = line.trim_start();
scan_nosemgrep_line(file, line, line_num, snippet, filter, out);
if trimmed.starts_with("///") || trimmed.starts_with("//!") || trimmed.starts_with("//") {
return;
}
let attr_in_code = line
.find("#[")
.map(|hi| rust_position_in_code(line, line_start, hi))
.unwrap_or(false);
if attr_in_code && let Some(caps) = RUST_ALLOW_RE.captures(line) {
let directive = caps.get(1).map(|m| m.as_str()).unwrap_or("allow");
let lints_raw = caps.get(2).map(|m| m.as_str()).unwrap_or("");
for lint in lints_raw.split(',') {
let lint = lint.trim();
if lint.is_empty() {
continue;
}
let lint_normalized = lint.trim_start_matches("clippy::").to_string();
let is_dead = lint_normalized == "dead_code";
let kind = if is_dead {
SilencerKind::DeadCode
} else {
SilencerKind::Allow
};
if want(filter, kind) {
out.push(SilencerMatch {
kind,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: Some(format!("{}({})", directive, lint)),
});
}
}
}
if attr_in_code && RUST_IGNORE_RE.is_match(line) && want(filter, SilencerKind::Ignore) {
out.push(SilencerMatch {
kind: SilencerKind::Ignore,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: None,
});
}
if let Some(unsafe_match) = RUST_UNSAFE_BLOCK_RE.find(line) {
if !rust_position_in_code(line, line_start, unsafe_match.start()) {
return;
}
let is_env_var = if RUST_ENV_VAR_CALL_RE.is_match(line) {
true
} else {
let mut depth: i32 =
line.matches('{').count() as i32 - line.matches('}').count() as i32;
let mut found = false;
for body in lines.iter().skip(idx + 1) {
if RUST_ENV_VAR_CALL_RE.is_match(body) {
found = true;
break;
}
depth += body.matches('{').count() as i32 - body.matches('}').count() as i32;
if depth <= 0 {
break;
}
if depth > 32 {
break;
}
}
found
};
let kind = if is_env_var {
SilencerKind::UnsafeEnvVar
} else {
SilencerKind::Unsafe
};
if want(filter, kind) {
out.push(SilencerMatch {
kind,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: None,
});
}
}
}
fn scan_js_ts_line(
file: &str,
line: &str,
line_num: usize,
snippet: &str,
filter: &HashSet<SilencerKind>,
out: &mut Vec<SilencerMatch>,
) {
if TS_IGNORE_RE.is_match(line) && want(filter, SilencerKind::TsIgnore) {
out.push(SilencerMatch {
kind: SilencerKind::TsIgnore,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: None,
});
}
if TS_EXPECT_ERR_RE.is_match(line) && want(filter, SilencerKind::TsExpectError) {
out.push(SilencerMatch {
kind: SilencerKind::TsExpectError,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: None,
});
}
if TS_NOCHECK_RE.is_match(line) && want(filter, SilencerKind::TsNocheck) {
out.push(SilencerMatch {
kind: SilencerKind::TsNocheck,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: None,
});
}
if want(filter, SilencerKind::EslintDisable)
&& let Some(caps) = ESLINT_DISABLE_RE.captures(line)
{
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::EslintDisable,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
}
fn scan_python_line(
file: &str,
line: &str,
line_num: usize,
snippet: &str,
filter: &HashSet<SilencerKind>,
out: &mut Vec<SilencerMatch>,
) {
if want(filter, SilencerKind::Noqa)
&& let Some(caps) = PY_NOQA_RE.captures(line)
{
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::Noqa,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
if want(filter, SilencerKind::TypeIgnore)
&& let Some(caps) = PY_TYPE_IGNORE_RE.captures(line)
{
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::TypeIgnore,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
if want(filter, SilencerKind::PylintDisable)
&& let Some(caps) = PY_PYLINT_RE.captures(line)
{
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::PylintDisable,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
if want(filter, SilencerKind::MypyIgnore)
&& let Some(caps) = PY_MYPY_RE.captures(line)
{
let body = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::MypyIgnore,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: body,
});
}
}
fn scan_shell_line(
file: &str,
line: &str,
line_num: usize,
snippet: &str,
filter: &HashSet<SilencerKind>,
out: &mut Vec<SilencerMatch>,
) {
if want(filter, SilencerKind::Shellcheck)
&& let Some(caps) = SHELLCHECK_RE.captures(line)
{
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::Shellcheck,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
}
fn scan_nosemgrep_line(
file: &str,
line: &str,
line_num: usize,
snippet: &str,
filter: &HashSet<SilencerKind>,
out: &mut Vec<SilencerMatch>,
) {
if !want(filter, SilencerKind::Nosemgrep) {
return;
}
let trimmed = line.trim_start();
if trimmed.starts_with("///") || trimmed.starts_with("//!") {
return;
}
if let Some(idx) = line.find("nosemgrep") {
let before = &line[..idx];
let quote_count = before.matches('"').count() - before.matches("\\\"").count();
if quote_count % 2 == 1 {
return;
}
}
if let Some(caps) = NOSEMGREP_RE.captures(line) {
let rule = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
out.push(SilencerMatch {
kind: SilencerKind::Nosemgrep,
file: file.to_string(),
line: line_num,
snippet: snippet.to_string(),
rule_id: rule,
});
}
}
pub fn load_semgrepignore(root: &Path) -> Vec<String> {
let path = root.join(".semgrepignore");
let Ok(content) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let mut out = Vec::new();
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let pattern = line.trim_end_matches('/');
if line.ends_with('/') || !pattern.contains('.') {
out.push(format!("{}/**", pattern));
} else {
out.push(pattern.to_string());
}
}
out
}
struct IgnoreMatcher {
set: Option<globset::GlobSet>,
prefixes: Vec<String>,
}
impl IgnoreMatcher {
fn is_match(&self, rel_path: &str) -> bool {
if let Some(set) = &self.set
&& set.is_match(rel_path)
{
return true;
}
self.prefixes.iter().any(|p| rel_path.starts_with(p))
}
}
fn build_ignore_matcher(patterns: &[String]) -> IgnoreMatcher {
let mut builder = globset::GlobSetBuilder::new();
let mut any = false;
let mut prefixes: Vec<String> = Vec::new();
for p in patterns {
let raw = p.trim();
if raw.is_empty() {
continue;
}
let cleaned = raw.trim_start_matches('/').to_string();
if let Ok(g) = globset::Glob::new(&cleaned) {
builder.add(g);
any = true;
}
if !cleaned.contains('/')
&& let Ok(g) = globset::Glob::new(&format!("**/{}", cleaned))
{
builder.add(g);
any = true;
}
if !cleaned.contains('*') && !cleaned.contains('?') && !cleaned.contains('[') {
prefixes.push(cleaned);
}
}
let set = if any { builder.build().ok() } else { None };
IgnoreMatcher { set, prefixes }
}
pub fn resolve_ignore_globs(root: &Path, include_semgrepignore: bool) -> Vec<String> {
if !include_semgrepignore {
return Vec::new();
}
load_semgrepignore(root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use tempfile::TempDir;
fn write(root: &Path, rel: &str, content: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn from_filter_recognizes_canonical_tokens() {
assert_eq!(
SilencerKind::from_filter("allow"),
Some(SilencerKind::Allow)
);
assert_eq!(
SilencerKind::from_filter("dead-code"),
Some(SilencerKind::DeadCode)
);
assert_eq!(
SilencerKind::from_filter("dead_code"),
Some(SilencerKind::DeadCode)
);
assert_eq!(
SilencerKind::from_filter("nosemgrep"),
Some(SilencerKind::Nosemgrep)
);
assert_eq!(
SilencerKind::from_filter("ts-ignore"),
Some(SilencerKind::TsIgnore)
);
assert_eq!(SilencerKind::from_filter("noqa"), Some(SilencerKind::Noqa));
assert_eq!(
SilencerKind::from_filter("unsafe-env-var"),
Some(SilencerKind::UnsafeEnvVar)
);
assert_eq!(SilencerKind::from_filter("not-a-kind"), None);
}
#[test]
fn rust_allow_dead_code_split_into_dead_code_bucket() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/lib.rs",
"#[allow(dead_code)]\nfn parked() {}\n#[allow(unused)]\nfn unused() {}\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let kinds: Vec<_> = matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&SilencerKind::DeadCode));
assert!(kinds.contains(&SilencerKind::Allow));
}
#[test]
fn rust_unsafe_env_var_distinguished_from_real_unsafe() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/main.rs",
"fn a() { unsafe {\n std::env::set_var(\"K\", \"v\");\n} }\n\
fn b() { unsafe { std::ptr::null::<u8>().read(); } }\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let kinds: Vec<_> = matches.iter().map(|m| m.kind).collect();
assert!(
kinds.contains(&SilencerKind::UnsafeEnvVar),
"env-var unsafe should be classified as UnsafeEnvVar, got: {:?}",
kinds
);
assert!(
kinds.contains(&SilencerKind::Unsafe),
"real unsafe should be classified as Unsafe, got: {:?}",
kinds
);
}
#[test]
fn nosemgrep_detected_cross_language() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/a.rs",
"// nosemgrep: rust.foo\nfn x() {}\n",
);
write(
tmp.path(),
"lib/b.ts",
"// nosemgrep\nexport const x = 1;\n",
);
write(tmp.path(), "scripts/c.py", "# nosemgrep\nx = 1\n");
let mut filter = HashSet::new();
filter.insert(SilencerKind::Nosemgrep);
let matches = scan_repo(tmp.path(), &filter, &[]);
assert_eq!(
matches.len(),
3,
"expected 3 nosemgrep matches, got {:?}",
matches
);
}
#[test]
fn ts_directives_classified_distinctly() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/x.ts",
"// @ts-ignore\nconst a = 1;\n// @ts-expect-error\nconst b = 2;\n// @ts-nocheck\nconst c = 3;\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let kinds: Vec<_> = matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&SilencerKind::TsIgnore));
assert!(kinds.contains(&SilencerKind::TsExpectError));
assert!(kinds.contains(&SilencerKind::TsNocheck));
}
#[test]
fn python_noqa_and_type_ignore_distinct() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"app.py",
"x = 1 # noqa: E501\ny = foo() # type: ignore[arg-type]\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let kinds: Vec<_> = matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&SilencerKind::Noqa));
assert!(kinds.contains(&SilencerKind::TypeIgnore));
}
#[test]
fn shellcheck_disable_detected() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"deploy.sh",
"#!/usr/bin/env bash\n# shellcheck disable=SC2086,SC2155\necho $foo\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
assert!(matches.iter().any(|m| m.kind == SilencerKind::Shellcheck));
}
#[test]
fn rust_ignore_test_attribute_detected() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"tests/it.rs",
"#[test]\n#[ignore]\nfn skipped() {}\n#[test]\n#[ignore = \"flaky\"]\nfn skipped_doc() {}\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let ignore_count = matches
.iter()
.filter(|m| m.kind == SilencerKind::Ignore)
.count();
assert_eq!(ignore_count, 2, "expected 2 #[ignore] matches");
}
#[test]
fn semgrepignore_excludes_listed_paths() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(tmp.path(), ".semgrepignore", "fixtures/\n");
write(tmp.path(), "src/a.rs", "// nosemgrep\nfn x() {}\n");
write(tmp.path(), "fixtures/b.rs", "// nosemgrep\nfn y() {}\n");
let globs = load_semgrepignore(tmp.path());
let matches = scan_repo(tmp.path(), &HashSet::new(), &globs);
let nosemgrep_files: Vec<_> = matches
.iter()
.filter(|m| m.kind == SilencerKind::Nosemgrep)
.map(|m| m.file.as_str())
.collect();
assert!(nosemgrep_files.iter().any(|f| f.starts_with("src/")));
assert!(
!nosemgrep_files.iter().any(|f| f.starts_with("fixtures/")),
"fixtures/ should be excluded via .semgrepignore, got: {:?}",
nosemgrep_files
);
}
#[test]
fn filter_restricts_kinds() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/lib.rs",
"// nosemgrep\n#[allow(dead_code)]\nfn x() {}\n",
);
let mut only_nose = HashSet::new();
only_nose.insert(SilencerKind::Nosemgrep);
let matches = scan_repo(tmp.path(), &only_nose, &[]);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].kind, SilencerKind::Nosemgrep);
}
#[test]
fn silencers_inside_multiline_string_literal_not_matched() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/help.rs",
"pub const HELP: &str = \"Surfaces every silencer:\n\
#[allow(...)] / #[expect(...)] (Rust), #[ignore] test attrs,\n\
unsafe { } blocks, // nosemgrep, @ts-ignore.\";\nfn real() {}\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
assert!(
matches.is_empty(),
"tokens inside a multi-line string constant must not be reported as silencers, got: {:?}",
matches
);
}
#[test]
fn real_silencer_after_multiline_string_still_detected() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/help.rs",
"pub const HELP: &str = \"mentions #[allow(dead_code)]\n\
across two lines unsafe { }\";\n#[allow(dead_code)]\nfn parked() {}\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
let kinds: Vec<_> = matches.iter().map(|m| m.kind).collect();
assert_eq!(
matches.len(),
1,
"exactly the real dead_code attribute should match, got: {:?}",
matches
);
assert!(kinds.contains(&SilencerKind::DeadCode));
}
#[test]
fn silencers_inside_block_comment_not_matched() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/lib.rs",
"/* example block comment:\n#[allow(dead_code)]\nunsafe { drop(0) }\n*/\nfn real() {}\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
assert!(
matches.is_empty(),
"tokens inside a multi-line block comment must not be reported, got: {:?}",
matches
);
}
#[test]
fn char_literal_quote_does_not_hide_following_unsafe() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/lib.rs",
"fn q() { let _c = '\"'; unsafe { std::ptr::null::<u8>().read(); } }\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
assert!(
matches.iter().any(|m| m.kind == SilencerKind::Unsafe),
"real unsafe after a quote char literal must still be detected, got: {:?}",
matches
);
}
#[test]
fn raw_string_with_quotes_does_not_hide_following_unsafe() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/lib.rs",
"fn p() { let _r = r#\"a single \" quote\"#; }\nfn q() { unsafe { std::ptr::null::<u8>().read(); } }\n",
);
let matches = scan_repo(tmp.path(), &HashSet::new(), &[]);
assert!(
matches.iter().any(|m| m.kind == SilencerKind::Unsafe),
"real unsafe after a raw string must still be detected, got: {:?}",
matches
);
}
#[test]
fn inventory_aggregates_counts_correctly() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.output()
.ok();
write(
tmp.path(),
"src/a.rs",
"#[allow(dead_code)]\nfn a() {}\n#[allow(dead_code)]\nfn b() {}\n",
);
let inv = inventory(tmp.path(), &HashSet::new(), &[]);
assert_eq!(inv.counts.get("dead-code"), Some(&2));
assert_eq!(inv.files_per_kind.get("dead-code"), Some(&1));
assert_eq!(inv.total, 2);
assert_eq!(inv.total_files, 1);
}
}