use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::packaging::scalar_field::{escape, value};
use crate::linter::rules::{DcfRule, DcfRuleContext, Example};
pub struct DescriptionMalformedMaintainer;
const ORPHANED: &str = "ORPHANED";
const LOCAL_SPECIALS: &str = "!#$%*/?|^{}`~&'+=_-";
const EXAMPLES: &[Example] = &[
Example {
caption: "A maintainer with no address, which is what R's \
`.valid_maintainer_field_regexp` mostly catches:",
source: "Package: mypkg\nVersion: 0.1.0\nMaintainer: Jane Doe\n",
},
Example {
caption: "Two maintainers, where R's `Maintainer` holds exactly one:",
source: "Package: mypkg\nVersion: 0.1.0\n\
Maintainer: Jane Doe <jane@example.com>, John Roe <john@example.org>\n",
},
Example {
caption: "A comma in an unquoted display name, which reads as a list of \
people:",
source: "Package: mypkg\nVersion: 0.1.0\nMaintainer: Doe, Jane <jane@example.com>\n",
},
];
impl DcfRule for DescriptionMalformedMaintainer {
fn id(&self) -> &'static str {
"description-malformed-maintainer"
}
fn description(&self) -> &'static str {
"Flag a `Maintainer` value R or CRAN will object to.\n\nR's \
`.valid_maintainer_field_regexp` wants exactly one `Name <address>`, or \
the literal `ORPHANED`. A **missing address** (`Maintainer: Jane Doe`) \
is the common case and fails it outright.\n\nThree CRAN pretest checks \
cover the rest of the field and are reported by the same rule, since \
they are one conversation about who maintains the package: text after \
the address, which is what **two maintainers** look like (R's own \
regexp accepts those, so this is the clause that catches them); an \
address with **no name** in front of it; and a **comma in an unquoted \
display name**, which reads as a list of people—`\"Doe, Jane\" \
<jane@example.com>` is the repair.\n\nR's regexp is ported as written \
and deliberately not tightened to RFC 5322: a quoted local part, a \
domain with no TLD, and a domain label starting with `-` are all \
addresses `R CMD check` accepts. A `Maintainer` wrapped across \
continuation lines is accepted too, exactly as R accepts it.\n\nAn \
absent or empty `Maintainer` is not this rule's finding: R derives one \
from `Authors@R`, and whether the package names a maintainer at all is \
`description-missing-field`'s subject.\n\nThere is no autofix: an \
address cannot be invented, a name cannot be invented, and whether a \
comma separates a surname from a given name or separates two people is \
a question only the author can answer."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &DcfRuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(field) = ctx.document.field("Maintainer") else {
return;
};
let Some((maintainer, range)) = value(&field) else {
return;
};
if maintainer == ORPHANED {
return;
}
let display = display_name(&maintainer);
let (message, suggestion) = if !is_valid_maintainer_field(&maintainer) {
if contains_an_address(&maintainer) {
(
format!(
"`{}` is not a valid `Maintainer` field: R requires one \
`Name <address>`, or `ORPHANED`",
escape(&maintainer),
),
"Write the field as `Name <name@example.com>`.",
)
} else {
(
format!("`{}` has no email address", escape(&maintainer)),
"Add the maintainer's address: `Name <name@example.com>`, or \
`ORPHANED` if the package has no maintainer.",
)
}
} else if names_more_than_one_person(&maintainer) {
(
format!("`{}` names more than one person", escape(&maintainer)),
"Name one maintainer here and credit everyone else in `Authors@R`: \
R's `Maintainer` is the single person to write to.",
)
} else if display.is_empty() {
(
format!("`{}` gives an address but no name", escape(&maintainer)),
"Put the maintainer's name in front of the address: \
`Name <name@example.com>`.",
)
} else if needs_quotes(display) {
(
format!(
"the maintainer name `{}` contains a comma but is not quoted",
escape(display),
),
"Quote the name (`\"Doe, Jane\" <jane@example.com>`), so the comma \
does not read as a second maintainer.",
)
} else {
return;
};
sink.push(Diagnostic {
rule: "description-malformed-maintainer",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("description-malformed-maintainer", message)
.with_suggestion(suggestion.to_string()),
fix: None,
});
}
}
fn is_valid_maintainer_field(maintainer: &str) -> bool {
maintainer == ORPHANED || bracketed_address(maintainer).is_some_and(is_valid_address)
}
fn bracketed_address(maintainer: &str) -> Option<&str> {
let inner = maintainer.strip_suffix('>')?;
let open = inner.rfind('<')?;
Some(&inner[open + 1..])
}
fn contains_an_address(maintainer: &str) -> bool {
maintainer
.find('<')
.is_some_and(|open| maintainer[open + 1..].contains('>'))
}
fn is_valid_address(address: &str) -> bool {
let Some((local, domain)) = address.rsplit_once('@') else {
return false;
};
is_valid_local_part(local) && is_valid_domain(domain)
}
fn is_valid_local_part(local: &str) -> bool {
if local.len() > 2 && local.starts_with('"') && local.ends_with('"') {
return true;
}
local
.split('.')
.all(|atom| !atom.is_empty() && atom.chars().all(is_local_char))
}
fn is_local_char(c: char) -> bool {
c.is_ascii_alphanumeric() || LOCAL_SPECIALS.contains(c)
}
fn is_valid_domain(domain: &str) -> bool {
domain.split('.').all(|label| {
!label.is_empty() && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
}
fn names_more_than_one_person(maintainer: &str) -> bool {
let folded = maintainer.replace('\n', " ");
let Some(open) = folded.find('<') else {
return true;
};
let rest = &folded[open + 1..];
match rest.find('>') {
Some(0) | None => true,
Some(close) => !rest[close + 1..].trim().is_empty(),
}
}
fn display_name(maintainer: &str) -> &str {
match maintainer.find('<') {
Some(open) => maintainer[..open].trim(),
None => maintainer.trim(),
}
}
fn needs_quotes(display: &str) -> bool {
let quoted = display.len() >= 2 && display.starts_with('"') && display.ends_with('"');
display.contains(',') && !quoted
}