use rpm_spec::ast::{PreambleItem, Section, Span, Tag, TagValue, TextBody, TextSegment};
use rpm_spec_profile::Profile;
use crate::diagnostic::{Applicability, Diagnostic, LintCategory, Severity, Suggestion};
use crate::lint::{Lint, LintMetadata};
use crate::visit::{self, Visit};
pub static SOURCE_WITHOUT_URL_METADATA: LintMetadata = LintMetadata {
id: "RPM125",
name: "source-without-url",
description: "`SourceN:` should be a URL (http/https/ftp) where the upstream tarball can \
be downloaded — Fedora packaging guideline.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct SourceWithoutUrl {
diagnostics: Vec<Diagnostic>,
}
impl SourceWithoutUrl {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for SourceWithoutUrl {
fn visit_preamble(&mut self, node: &'ast PreambleItem<Span>) {
if matches!(node.tag, Tag::Source(_))
&& let TagValue::Text(t) = &node.value
&& needs_url(t)
{
self.diagnostics.push(
Diagnostic::new(
&SOURCE_WITHOUT_URL_METADATA,
Severity::Warn,
"`Source` value is a filename, not a download URL; \
Fedora policy expects an `http://` / `https://` / `ftp://` link",
node.data,
)
.with_suggestion(Suggestion::new(
"rewrite as the full upstream download URL (the filename is \
derived automatically via `basename`)",
Vec::new(),
Applicability::Manual,
)),
);
}
visit::walk_preamble(self, node);
}
}
impl Lint for SourceWithoutUrl {
fn metadata(&self) -> &'static LintMetadata {
&SOURCE_WITHOUT_URL_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn applies_to_profile(&self, profile: &Profile) -> bool {
crate::rules::util::is_fedora_or_rhel(profile)
}
}
fn needs_url(t: &rpm_spec::ast::Text) -> bool {
let mut has_literal = false;
for seg in &t.segments {
if let TextSegment::Literal(s) = seg {
if s.contains("://") {
return false;
}
if !s.trim().is_empty() {
has_literal = true;
}
}
}
has_literal
}
const MAX_SUBJECT_LEN: usize = 50;
pub static DESCRIPTION_LEADS_WITH_THIS_PACKAGE_METADATA: LintMetadata = LintMetadata {
id: "RPM126",
name: "description-leads-with-this-package",
description: "`%description` body begins with `This package …` / `The X package …` — \
Fedora style guide prefers leading with the subject of the description.",
default_severity: Severity::Allow,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct DescriptionLeadsWithThisPackage {
diagnostics: Vec<Diagnostic>,
}
impl DescriptionLeadsWithThisPackage {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for DescriptionLeadsWithThisPackage {
fn visit_section(&mut self, node: &'ast Section<Span>) {
if let Section::Description { body, data, .. } = node
&& let Some(first) = first_meaningful_line(body)
&& leads_with_this_or_the_package(&first)
{
self.diagnostics.push(
Diagnostic::new(
&DESCRIPTION_LEADS_WITH_THIS_PACKAGE_METADATA,
Severity::Warn,
"`%description` opens with a `This package …` / `The X package …` \
filler phrase — start with the subject directly",
*data,
)
.with_suggestion(Suggestion::new(
"rewrite the opening sentence to begin with the subject",
Vec::new(),
Applicability::Manual,
)),
);
}
visit::walk_section(self, node);
}
}
impl Lint for DescriptionLeadsWithThisPackage {
fn metadata(&self) -> &'static LintMetadata {
&DESCRIPTION_LEADS_WITH_THIS_PACKAGE_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
fn first_meaningful_line(body: &TextBody) -> Option<String> {
for line in &body.lines {
let mut buf = String::new();
let mut leading_macro = false;
for seg in &line.segments {
match seg {
TextSegment::Literal(s) => buf.push_str(s),
TextSegment::Macro(_) => {
if buf.trim().is_empty() {
leading_macro = true;
}
break;
}
_ => break,
}
}
if leading_macro {
return None;
}
if !buf.trim().is_empty() {
return Some(buf);
}
}
None
}
fn leads_with_this_or_the_package(line: &str) -> bool {
let lower = line.trim_start().to_ascii_lowercase();
if lower.starts_with("this package ") {
return true;
}
if lower.starts_with("this is the ") {
return true;
}
if let Some(rest) = lower.strip_prefix("the ")
&& let Some(idx) = rest.find(" package ")
&& idx > 0
&& idx < MAX_SUBJECT_LEN
{
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
use rpm_spec_profile::{Family, Profile};
fn run<L: Lint>(src: &str, mut lint: L) -> Vec<Diagnostic> {
let outcome = parse(src);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
fn run_with_profile(src: &str, profile: &Profile) -> Vec<Diagnostic> {
let lint = SourceWithoutUrl::new();
if !lint.applies_to_profile(profile) {
return Vec::new();
}
run(src, lint)
}
#[test]
fn rpm125_flags_filename_only() {
let src = "Name: x\nVersion: 1\nSource0: foo-1.0.tar.gz\n";
let diags = run(src, SourceWithoutUrl::new());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM125");
}
#[test]
fn rpm125_silent_for_http_url() {
let src = "Name: x\nSource0: https://example.org/foo-1.0.tar.gz\n";
assert!(run(src, SourceWithoutUrl::new()).is_empty());
}
#[test]
fn rpm125_silent_for_ftp_url() {
let src = "Name: x\nSource0: ftp://example.org/foo.tar.gz\n";
assert!(run(src, SourceWithoutUrl::new()).is_empty());
}
#[test]
fn rpm125_flags_filename_with_macros() {
let src = "Name: x\nSource0: gcc-%{version}-%{DATE}.tar.xz\n";
let diags = run(src, SourceWithoutUrl::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm125_silent_for_pure_macro_value() {
let src = "Name: x\nSource0: %{upstream_tarball}\n";
assert!(run(src, SourceWithoutUrl::new()).is_empty());
}
#[test]
fn rpm125_silent_for_url_with_macros() {
let src = "Name: x\nSource3: https://gcc.gnu.org/pub/gcc/isl-%{isl_version}.tar.bz2\n";
assert!(run(src, SourceWithoutUrl::new()).is_empty());
}
#[test]
fn rpm125_flags_numbered_source() {
let src = "Name: x\nSource17: extra-setup.in\n";
let diags = run(src, SourceWithoutUrl::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm125_fires_on_fedora_profile() {
let mut p = Profile::default();
p.identity.family = Some(Family::Fedora);
let src = "Name: x\nSource0: hello.tar.gz\n";
let diags = run_with_profile(src, &p);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM125");
}
#[test]
fn rpm125_silent_on_alt_profile() {
let mut p = Profile::default();
p.identity.family = Some(Family::Alt);
let src = "Name: x\nSource0: hello.tar.gz\n";
assert!(run_with_profile(src, &p).is_empty());
}
#[test]
fn rpm125_silent_on_generic_profile() {
let mut p = Profile::default();
p.identity.family = Some(Family::Generic);
let src = "Name: x\nSource0: hello.tar.gz\n";
assert!(run_with_profile(src, &p).is_empty());
}
#[test]
fn rpm126_flags_this_package_opening() {
let src = "\
Name: x
%description
This package contains the GNU C++ compiler.
%files
";
let diags = run(src, DescriptionLeadsWithThisPackage::new());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM126");
}
#[test]
fn rpm126_flags_the_x_package_opening() {
let src = "\
Name: x
%description
The libstdc++ package contains the C++ standard library.
%files
";
let diags = run(src, DescriptionLeadsWithThisPackage::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm126_flags_this_is_the_opening() {
let src = "\
Name: x
%description
This is the GNU implementation of the standard C++ libraries.
%files
";
let diags = run(src, DescriptionLeadsWithThisPackage::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm126_silent_for_subject_first_opening() {
let src = "\
Name: x
%description
C++ compiler from the GNU Compiler Collection.
%files
";
assert!(run(src, DescriptionLeadsWithThisPackage::new()).is_empty());
}
#[test]
fn rpm126_silent_when_first_line_is_blank() {
let src = "\
Name: x
%description
C++ compiler from GCC.
%files
";
assert!(run(src, DescriptionLeadsWithThisPackage::new()).is_empty());
}
#[test]
fn rpm126_silent_when_first_line_starts_with_macro() {
let src = "\
Name: x
%description
%{summary} — extended description.
%files
";
assert!(run(src, DescriptionLeadsWithThisPackage::new()).is_empty());
}
}