use std::collections::BTreeSet;
use rpm_spec::ast::{Span, SpecFile};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::shell::{CommandUseIndex, SectionRef, ShellToken, first_non_flag_arg};
use crate::visit::Visit;
use rpm_spec_profile::Profile;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM390",
name: "buildsystem-macro-modernization",
description: "Build script invokes a build system directly (`cmake`, `meson`, `cargo`, …) \
instead of the distro's wrapper macro (`%cmake`, `%meson`, `%cargo_build`, …). \
The wrappers plumb `%{optflags}` and per-arch defaults; bare calls drop them.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct BuildsystemMacroModernization {
diagnostics: Vec<Diagnostic>,
available: BTreeSet<&'static str>,
}
impl BuildsystemMacroModernization {
pub fn new() -> Self {
Self::default()
}
}
const KNOWN_MACROS: &[&str] = &[
"cmake",
"cmake_build",
"cmake_install",
"meson",
"meson_build",
"meson_install",
"cargo_build",
"cargo_install",
"py3_build",
"py3_install",
"pyproject_wheel",
"pyproject_install",
"gobuild",
"goinstall",
];
impl<'ast> Visit<'ast> for BuildsystemMacroModernization {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
if self.available.is_empty() {
return;
}
let idx = CommandUseIndex::from_spec(spec);
let mut seen: BTreeSet<(usize, &'static str)> = BTreeSet::new();
for call in idx.all() {
let SectionRef::BuildScript { kind, .. } = call.location else {
continue;
};
if !matches!(
kind,
rpm_spec::ast::BuildScriptKind::Build | rpm_spec::ast::BuildScriptKind::Install
) {
continue;
}
let Some(cmd) = call.name.as_deref() else {
continue;
};
let Some(suggestion) = suggest_macro(cmd, &call.tokens, &self.available) else {
continue;
};
let key = (call.location.section_span().start_byte, suggestion);
if !seen.insert(key) {
continue;
}
self.diagnostics.push(Diagnostic::new(
&METADATA,
Severity::Warn,
format!(
"build script invokes `{cmd}` directly; the active profile defines \
`%{suggestion}` — prefer the macro so distro hardening flags and \
per-arch defaults are applied (replace with `%{suggestion}`)"
),
call.location.section_span(),
));
}
}
}
fn suggest_macro(
cmd: &str,
tokens: &[ShellToken],
available: &BTreeSet<&'static str>,
) -> Option<&'static str> {
let sub = first_non_flag_arg(tokens);
let candidate: &'static str = match cmd {
"cmake" => match sub.as_deref() {
_ if has_token(tokens, "--build") => "cmake_build",
_ if has_token(tokens, "--install") => "cmake_install",
_ => "cmake",
},
"meson" => match sub.as_deref() {
Some("setup") | None => "meson",
Some("compile") => "meson_build",
Some("install") => "meson_install",
_ => return None,
},
"cargo" => match sub.as_deref() {
Some("build") => "cargo_build",
Some("install") => "cargo_install",
_ => return None,
},
"python" | "python3" => {
if has_token(tokens, "setup.py") {
if has_token(tokens, "build") {
"py3_build"
} else if has_token(tokens, "install") {
"py3_install"
} else {
return None;
}
} else if has_subseq(tokens, &["-m", "build"]) {
"pyproject_wheel"
} else {
return None;
}
}
"go" => match sub.as_deref() {
Some("build") => "gobuild",
Some("install") => "goinstall",
_ => return None,
},
_ => return None,
};
if available.contains(&candidate) {
Some(candidate)
} else {
None
}
}
fn has_token(tokens: &[ShellToken], needle: &str) -> bool {
tokens
.iter()
.any(|t| t.literal_str().as_deref() == Some(needle))
}
fn has_subseq(tokens: &[ShellToken], needles: &[&str]) -> bool {
if needles.is_empty() {
return true;
}
tokens.windows(needles.len()).any(|win| {
win.iter()
.zip(needles)
.all(|(tok, n)| tok.literal_str().as_deref() == Some(*n))
})
}
impl Lint for BuildsystemMacroModernization {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn applies_to_profile(&self, profile: &Profile) -> bool {
KNOWN_MACROS.iter().any(|m| profile.macros.get(m).is_some())
}
fn set_profile(&mut self, profile: &Profile) {
self.available = KNOWN_MACROS
.iter()
.copied()
.filter(|m| profile.macros.get(m).is_some())
.collect();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
use rpm_spec_profile::{MacroEntry, Profile, Provenance};
fn profile_with(macros: &[&str]) -> Profile {
let mut p = Profile::default();
for m in macros {
p.macros
.insert(*m, MacroEntry::literal("body", Provenance::Override));
}
p
}
fn run_with(src: &str, profile: &Profile) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = BuildsystemMacroModernization::new();
lint.set_profile(profile);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_bare_cmake_when_macro_available() {
let src = "Name: x\n%build\ncmake .\n";
let diags = run_with(src, &profile_with(&["cmake"]));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM390");
assert!(diags[0].message.contains("%cmake"));
}
#[test]
fn flags_cmake_build() {
let src = "Name: x\n%build\ncmake --build build\n";
let diags = run_with(src, &profile_with(&["cmake_build"]));
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("%cmake_build"));
}
#[test]
fn flags_cmake_install() {
let src = "Name: x\n%install\ncmake --install build\n";
let diags = run_with(src, &profile_with(&["cmake_install"]));
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("%cmake_install"));
}
#[test]
fn flags_meson_setup() {
let src = "Name: x\n%build\nmeson setup builddir\n";
let diags = run_with(src, &profile_with(&["meson"]));
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("%meson"));
}
#[test]
fn flags_cargo_build() {
let src = "Name: x\n%build\ncargo build --release\n";
let diags = run_with(src, &profile_with(&["cargo_build"]));
assert_eq!(diags.len(), 1);
}
#[test]
fn flags_python_setup_py_build() {
let src = "Name: x\n%build\npython3 setup.py build\n";
let diags = run_with(src, &profile_with(&["py3_build"]));
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("%py3_build"));
}
#[test]
fn flags_python_m_build() {
let src = "Name: x\n%build\npython3 -m build --wheel\n";
let diags = run_with(src, &profile_with(&["pyproject_wheel"]));
assert_eq!(diags.len(), 1);
}
#[test]
fn flags_go_build() {
let src = "Name: x\n%build\ngo build ./cmd/foo\n";
let diags = run_with(src, &profile_with(&["gobuild"]));
assert_eq!(diags.len(), 1);
}
#[test]
fn silent_when_macro_unavailable() {
let src = "Name: x\n%build\ncmake .\n";
assert!(run_with(src, &Profile::default()).is_empty());
}
#[test]
fn silent_for_unrelated_command() {
let src = "Name: x\n%build\nmake\n";
let diags = run_with(src, &profile_with(&["cmake", "meson"]));
assert!(diags.is_empty());
}
#[test]
fn silent_in_check_section() {
let src = "Name: x\n%check\nmeson test\n";
assert!(run_with(src, &profile_with(&["meson"])).is_empty());
}
#[test]
fn deduplicates_repeated_calls() {
let src = "Name: x\n%build\ncmake .\ncmake .\n";
let diags = run_with(src, &profile_with(&["cmake"]));
assert_eq!(diags.len(), 1);
}
}