use rpm_spec::ast::{Span, SpecFile};
use rpm_spec_profile::{Family, Profile};
use crate::diagnostic::{Applicability, Diagnostic, LintCategory, Severity, Suggestion};
use crate::lint::{Lint, LintMetadata};
use crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM129",
name: "bcond-on-non-fedora",
description: "`%bcond_with` / `%bcond_without` are Fedora/RHEL-specific build-option macros; \
use `%define NAME 1` + plain `%if` on other distros.",
default_severity: Severity::Warn,
category: LintCategory::Packaging,
};
#[derive(Debug, Default)]
pub struct BcondOnNonFedora {
diagnostics: Vec<Diagnostic>,
source: Option<String>,
}
impl BcondOnNonFedora {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for BcondOnNonFedora {
fn visit_spec(&mut self, _spec: &'ast SpecFile<Span>) {
let Some(source) = self.source.as_deref() else {
return;
};
for (start, kind) in find_bcond_uses(source) {
let end = start
+ match kind {
BcondKind::With => "%bcond_with".len(),
BcondKind::Without => "%bcond_without".len(),
};
let macro_name = match kind {
BcondKind::With => "%bcond_with",
BcondKind::Without => "%bcond_without",
};
self.diagnostics.push(
Diagnostic::new(
&METADATA,
METADATA.default_severity,
format!(
"`{macro_name}` is a Fedora/RHEL macro and isn't natively supported on this distro"
),
Span::from_bytes(start, end),
)
.with_suggestion(Suggestion::new(
"rewrite as `%define NAME 1` (default-on) or `%define NAME 0` (default-off) \
and replace `%{with NAME}` with `%if %{NAME}` checks",
Vec::new(),
Applicability::Manual,
)),
);
}
}
}
impl Lint for BcondOnNonFedora {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn set_source(&mut self, source: &str) {
self.source = Some(source.to_owned());
}
fn applies_to_profile(&self, profile: &Profile) -> bool {
match profile.identity.family {
Some(Family::Alt | Family::Opensuse | Family::Mageia | Family::Generic) => true,
Some(Family::Fedora | Family::Rhel) => false,
None => false,
Some(_) => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BcondKind {
With,
Without,
}
fn find_bcond_uses(src: &str) -> Vec<(usize, BcondKind)> {
let mut out = Vec::new();
let bytes = src.as_bytes();
let mut line_start = 0;
let mut i = 0;
while i <= bytes.len() {
if i == bytes.len() || bytes[i] == b'\n' {
if let Some(hit) = scan_line(src, line_start, i) {
out.push(hit);
}
line_start = i + 1;
}
i += 1;
}
out
}
fn scan_line(src: &str, start: usize, end: usize) -> Option<(usize, BcondKind)> {
let line = src.get(start..end)?;
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return None;
}
let leading_ws = line.len() - trimmed.len();
if let Some(rest) = trimmed.strip_prefix("%bcond_without")
&& rest.chars().next().is_none_or(|c| !is_ident_char(c))
{
return Some((start + leading_ws, BcondKind::Without));
}
if let Some(rest) = trimmed.strip_prefix("%bcond_with")
&& rest.chars().next().is_none_or(|c| !is_ident_char(c))
{
return Some((start + leading_ws, BcondKind::With));
}
None
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::util::make_test_profile;
use crate::session::parse;
fn run(src: &str, profile: &Profile) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = BcondOnNonFedora::new();
if !lint.applies_to_profile(profile) {
return Vec::new();
}
lint.set_source(src);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn fires_on_alt_with_bcond_with() {
let profile = make_test_profile(Some(Family::Alt), None, &[], &[]);
let src = "Name: x\n%bcond_with python3\n";
let diags = run(src, &profile);
assert_eq!(diags.len(), 1, "got {diags:?}");
assert_eq!(diags[0].lint_id, "RPM129");
assert!(diags[0].message.contains("%bcond_with"));
}
#[test]
fn fires_on_opensuse_with_bcond_without() {
let profile = make_test_profile(Some(Family::Opensuse), None, &[], &[]);
let src = "Name: x\n%bcond_without gtk\n";
let diags = run(src, &profile);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("%bcond_without"));
}
#[test]
fn silent_on_fedora() {
let profile = make_test_profile(Some(Family::Fedora), Some(".fc40"), &[], &[]);
let src = "Name: x\n%bcond_with python3\n";
assert!(
run(src, &profile).is_empty(),
"Fedora natively supports %bcond_*"
);
}
#[test]
fn silent_on_rhel() {
let profile = make_test_profile(Some(Family::Rhel), Some(".el9"), &[], &[]);
let src = "Name: x\n%bcond_with python3\n";
assert!(
run(src, &profile).is_empty(),
"RHEL inherits %bcond_* from upstream"
);
}
#[test]
fn silent_on_alt_without_bcond() {
let profile = make_test_profile(Some(Family::Alt), None, &[], &[]);
let src = "Name: x\n%define with_python 1\n%if %{with_python}\n%endif\n";
assert!(run(src, &profile).is_empty());
}
#[test]
fn silent_when_no_family() {
let profile = make_test_profile(None, None, &[], &[]);
let src = "Name: x\n%bcond_with python3\n";
assert!(run(src, &profile).is_empty());
}
#[test]
fn skips_bcond_inside_comment() {
let profile = make_test_profile(Some(Family::Alt), None, &[], &[]);
let src = "Name: x\n# %bcond_with python3 is Fedora-only\n";
assert!(run(src, &profile).is_empty(), "comments don't count");
}
#[test]
fn fires_multiple_uses_independently() {
let profile = make_test_profile(Some(Family::Mageia), None, &[], &[]);
let src = "Name: x\n%bcond_with python3\n%bcond_without docs\n";
let diags = run(src, &profile);
assert_eq!(diags.len(), 2);
}
#[test]
fn handles_leading_whitespace() {
let profile = make_test_profile(Some(Family::Alt), None, &[], &[]);
let src = "Name: x\n %bcond_with python3\n";
let diags = run(src, &profile);
assert_eq!(diags.len(), 1);
}
}