use rpm_spec::ast::{Conditional, Span, SpecFile, SpecItem, Tag};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::visit::Visit;
use rpm_spec_profile::{Family, Profile};
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM307",
name: "patch-status-comment-missing",
description: "openSUSE: every `Patch:` tag should be preceded by a comment carrying a \
status marker (`PATCH-FIX-UPSTREAM`, `PATCH-FIX-OPENSUSE`, \
`PATCH-FEATURE-*`, `PATCH-NEEDS-*`, ...). Without it reviewers can't tell \
whether the patch is upstream-bound.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct PatchStatusCommentMissing {
diagnostics: Vec<Diagnostic>,
enabled: bool,
}
impl PatchStatusCommentMissing {
pub fn new() -> Self {
Self::default()
}
}
const STATUS_MARKER_PREFIXES: &[&str] = &[
"PATCH-FIX-",
"PATCH-FEATURE-",
"PATCH-NEEDS-",
"PATCH-PORT-",
"PATCH-ENABLE-",
"PATCH-DISABLE-",
"PATCH-MISSING-TAG",
];
impl<'ast> Visit<'ast> for PatchStatusCommentMissing {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
if !self.enabled {
return;
}
scan_items(&spec.items, &mut self.diagnostics);
}
}
fn scan_items(items: &[SpecItem<Span>], out: &mut Vec<Diagnostic>) {
for (i, item) in items.iter().enumerate() {
match item {
SpecItem::Preamble(p) => {
if !matches!(p.tag, Tag::Patch(_)) {
continue;
}
if preceding_comments_have_marker(items, i) {
continue;
}
out.push(Diagnostic::new(
&METADATA,
Severity::Warn,
"openSUSE: this `Patch:` tag has no status-marker comment \
(`PATCH-FIX-UPSTREAM` / `PATCH-FIX-OPENSUSE` / `PATCH-NEEDS-REBASE` / ...)",
p.data,
));
}
SpecItem::Conditional(c) => scan_conditional(c, out),
_ => {}
}
}
}
fn scan_conditional(cond: &Conditional<Span, SpecItem<Span>>, out: &mut Vec<Diagnostic>) {
for branch in &cond.branches {
scan_items(&branch.body, out);
}
if let Some(els) = &cond.otherwise {
scan_items(els, out);
}
}
fn preceding_comments_have_marker(items: &[SpecItem<Span>], idx: usize) -> bool {
for item in items[..idx].iter().rev() {
match item {
SpecItem::Comment(c) => {
if comment_has_marker(&c.text) {
return true;
}
}
SpecItem::Blank => continue,
_ => return false,
}
}
false
}
fn comment_has_marker(text: &rpm_spec::ast::Text) -> bool {
let Some(lit) = text.literal_str() else {
return false;
};
let Some(first_token) = lit.split_whitespace().next() else {
return false;
};
let first_upper = first_token.to_ascii_uppercase();
STATUS_MARKER_PREFIXES
.iter()
.any(|p| first_upper.starts_with(p))
}
impl Lint for PatchStatusCommentMissing {
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 {
matches!(profile.identity.family, Some(Family::Opensuse))
}
fn set_profile(&mut self, profile: &Profile) {
self.enabled = matches!(profile.identity.family, Some(Family::Opensuse));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn opensuse() -> Profile {
let mut p = Profile::default();
p.identity.family = Some(Family::Opensuse);
p
}
fn fedora() -> Profile {
let mut p = Profile::default();
p.identity.family = Some(Family::Fedora);
p
}
fn run_with(src: &str, profile: &Profile) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = PatchStatusCommentMissing::new();
lint.set_profile(profile);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_patch_without_status_comment() {
let src = "Name: x\nPatch0: foo.patch\n";
let diags = run_with(src, &opensuse());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM307");
}
#[test]
fn silent_with_upstream_marker() {
let src =
"Name: x\n# PATCH-FIX-UPSTREAM foo.patch alice -- merged in 1.2\nPatch0: foo.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn silent_with_opensuse_marker() {
let src =
"Name: x\n# PATCH-FIX-OPENSUSE crash.patch bob -- distro only\nPatch1: crash.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn silent_with_needs_rebase_marker() {
let src = "Name: x\n# PATCH-NEEDS-REBASE port.patch carol\nPatch0: port.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn silent_when_marker_is_lowercase() {
let src = "Name: x\n# patch-fix-upstream foo.patch\nPatch0: foo.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn flags_when_neighbour_comment_lacks_marker() {
let src = "Name: x\n# arbitrary comment, no marker\nPatch0: foo.patch\n";
assert_eq!(run_with(src, &opensuse()).len(), 1);
}
#[test]
fn silent_with_blank_between_comment_and_patch() {
let src = "Name: x\n# PATCH-FIX-UPSTREAM foo.patch\n\nPatch0: foo.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn flags_only_unmarked_patch_in_mixed_list() {
let src = "Name: x\n# PATCH-FIX-UPSTREAM a.patch\nPatch0: a.patch\nPatch1: b.patch\n";
let diags = run_with(src, &opensuse());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn silent_on_fedora_profile() {
let src = "Name: x\nPatch0: foo.patch\n";
assert!(run_with(src, &fedora()).is_empty());
}
#[test]
fn silent_on_generic_profile() {
let src = "Name: x\nPatch0: foo.patch\n";
assert!(run_with(src, &Profile::default()).is_empty());
}
#[test]
fn marker_inside_a_longer_comment_block_counts() {
let src = "Name: x\n# PATCH-FIX-UPSTREAM foo.patch alice\n# Detailed description of the fix, multi-line.\nPatch0: foo.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn marker_below_description_counts() {
let src = "Name: x\n# Detailed description of the fix.\n# PATCH-FIX-UPSTREAM foo.patch alice\nPatch0: foo.patch\n";
assert!(run_with(src, &opensuse()).is_empty());
}
#[test]
fn flags_description_mentioning_marker_in_prose() {
let src =
"Name: x\n# Discussion about the PATCH-FIX-UPSTREAM convention\nPatch0: foo.patch\n";
assert_eq!(run_with(src, &opensuse()).len(), 1);
}
#[test]
fn flags_patch_inside_conditional_without_marker() {
let src = "Name: x\n%if 0%{?suse_version}\nPatch99: foo.patch\n%endif\n";
assert_eq!(run_with(src, &opensuse()).len(), 1);
}
#[test]
fn silent_for_patch_inside_conditional_with_marker() {
let src = "Name: x\n%if 0%{?suse_version}\n# PATCH-FIX-OPENSUSE foo.patch\nPatch99: foo.patch\n%endif\n";
assert!(run_with(src, &opensuse()).is_empty());
}
}