use rpm_spec::ast::{ShellBody, Span, SpecFile};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::shell::{for_each_buildscript, tokenize_line};
use crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM387",
name: "j1-without-comment",
description: "Build script forces serial make (`make -j1`) with no comment explaining why. \
`-j1` is often leftover debug or an obsolete workaround for an upstream race; \
add a comment so reviewers can tell intentional from accidental.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct J1WithoutComment {
diagnostics: Vec<Diagnostic>,
}
impl J1WithoutComment {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for J1WithoutComment {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
for_each_buildscript(spec, |_kind, body, section_span| {
scan_body(body, section_span, &mut self.diagnostics);
});
}
}
fn scan_body(body: &ShellBody, section_span: Span, out: &mut Vec<Diagnostic>) {
for (idx, line) in body.lines.iter().enumerate() {
if !line_has_j1(line) {
continue;
}
if neighbour_has_comment(&body.lines, idx) {
continue;
}
out.push(Diagnostic::new(
&METADATA,
Severity::Warn,
"build script forces `make -j1` with no comment explaining why; add a `#`-comment \
on the preceding line so reviewers can tell intentional serial build from leftover \
debug",
section_span,
));
return;
}
}
fn line_has_j1(line: &rpm_spec::ast::Text) -> bool {
let tokens = tokenize_line(line);
let mut iter = tokens.iter().peekable();
while let Some(tok) = iter.next() {
let Some(lit) = tok.literal_str() else {
continue;
};
if lit == "-j1" {
return true;
}
if lit == "-j"
&& let Some(next) = iter.peek()
&& let Some(nlit) = next.literal_str()
&& nlit == "1"
{
return true;
}
}
false
}
fn neighbour_has_comment(lines: &[rpm_spec::ast::Text], idx: usize) -> bool {
if let Some(lit) = lines[idx].literal_str()
&& let Some(hash) = lit.find('#')
{
let before = &lit[..hash];
if before.is_empty() || before.ends_with(char::is_whitespace) {
return true;
}
}
let mut j = idx;
while j > 0 {
j -= 1;
let Some(lit) = lines[j].literal_str() else {
return false;
};
let trimmed = lit.trim();
if trimmed.is_empty() {
continue;
}
return trimmed.starts_with('#');
}
false
}
impl Lint for J1WithoutComment {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn run(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = J1WithoutComment::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_make_j1_without_comment() {
let src = "Name: x\n%build\nmake -j1\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM387");
}
#[test]
fn flags_make_j_space_1_without_comment() {
let src = "Name: x\n%build\nmake -j 1\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn silent_with_preceding_comment() {
let src = "Name: x\n%build\n# upstream race in code generator, see #1234\nmake -j1\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_with_trailing_comment_on_same_line() {
let src = "Name: x\n%build\nmake -j1 # racy bison generator\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_with_comment_separated_by_blank() {
let src = "Name: x\n%build\n# upstream race\n\nmake -j1\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_for_normal_parallel_make() {
let src = "Name: x\n%build\nmake %{?_smp_mflags}\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_for_higher_j_value() {
let src = "Name: x\n%build\nmake -j4\n";
assert!(run(src).is_empty());
}
#[test]
fn flags_j1_in_install_section() {
let src = "Name: x\n%install\nmake -j1 install\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn silent_when_first_line_is_just_a_comment() {
let src = "Name: x\n%build\n# some comment\nmake\n";
assert!(run(src).is_empty());
}
}