use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::packaging::scalar_field::{escape, value};
use crate::linter::rules::{DcfRule, DcfRuleContext, Example};
pub struct DescriptionMalformedVersion;
const LARGE_COMPONENT: u64 = 1234;
const YEAR_BAND: std::ops::RangeInclusive<u64> = 1900..=2999;
const DEV_COMPONENT: u64 = 9000;
const EXAMPLES: &[Example] = &[
Example {
caption: "A version R's `valid_package_version` rejects, since a \
component has to be digits:",
source: "Package: mypkg\nVersion: 1.0.0-beta\n",
},
Example {
caption: "A component with a leading zero, which sorts one way as text \
and another as a version:",
source: "Package: mypkg\nVersion: 1.01\n",
},
Example {
caption: "A component too large to be a release number:",
source: "Package: mypkg\nVersion: 1.0.5000\n",
},
];
impl DcfRule for DescriptionMalformedVersion {
fn id(&self) -> &'static str {
"description-malformed-version"
}
fn description(&self) -> &'static str {
"Flag a `Version` value R or CRAN will object to.\n\nR's \
`valid_package_version` is `([[:digit:]]+[.-]){1,}[[:digit:]]+`: runs \
of digits joined by `.` or `-`. The trailing run is written separately \
from the repeated group, so a version has **at least two \
components**—a bare `Version: 1` is one R rejects, and so is any \
component that is not digits (`1.0.0-beta`, `v1.0`).\n\nTwo CRAN \
pretest NOTEs are reported by the same rule, since the repair is the \
same: a component with a **leading zero** (`1.01`, which sorts before \
`1.1` as text and equal to it as a version), and an **implausibly \
large** component (1234 or more). Calendar versioning is exempt from \
both, exactly as CRAN exempts it: `2026.01` keeps its zero, and a \
four-digit component that reads as a year is not an absurd \
one.\n\nThe digit class is matched as ASCII, exactly as R matches it \
under a UTF-8 locale—note that this is the opposite of \
`description-malformed-name`, whose letter class is Unicode there. A \
description declaring `Priority: base` is exempt, since a base \
package's version is R's own to spell.\n\nAn absent or empty `Version` \
is `description-missing-field`'s finding, not this one's.\n\nThere is \
no autofix: which number a release carries is a decision about the \
release, and it is also in the package's tags, its `NEWS.md`, and every \
constraint a dependent puts on it."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &DcfRuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if declares_base_priority(ctx) {
return;
}
let Some(field) = ctx.document.field("Version") else {
return;
};
let Some((version, range)) = value(&field) else {
return;
};
let (message, suggestion) = if !is_valid_package_version(&version) {
(
format!(
"`{}` is not a valid package version: R requires runs of digits \
joined by `.` or `-`",
escape(&version),
),
"Renumber the release: at least two components, each one digits, \
separated by `.` or `-`.",
)
} else if has_leading_zero_component(&version) {
(
format!("`{version}` has a component with a leading zero"),
"Drop the leading zero: `1.01` and `1.1` are the same version to R, \
but not to anything that sorts the text.",
)
} else if let Some(component) = absurd_component(&version) {
(
format!("`{version}` has an implausibly large component (`{component}`)"),
"Check the number: a component of 1234 or more is usually a typo or a \
date in the wrong slot.",
)
} else {
return;
};
sink.push(Diagnostic {
rule: "description-malformed-version",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("description-malformed-version", message)
.with_suggestion(suggestion.to_string()),
fix: None,
});
}
}
fn declares_base_priority(ctx: &DcfRuleContext<'_>) -> bool {
ctx.document
.field("Priority")
.is_some_and(|field| field.folded_value().trim() == "base")
}
fn is_valid_package_version(version: &str) -> bool {
let mut rest = version;
let mut separators = 0usize;
loop {
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if digits == 0 {
return false;
}
rest = &rest[digits..];
match rest.as_bytes().first() {
Some(b'.' | b'-') => {
separators += 1;
rest = &rest[1..];
}
Some(_) => return false,
None => return separators >= 1,
}
}
}
fn has_leading_zero_component(version: &str) -> bool {
if is_calendar_versioned(version) {
return false;
}
let bytes = version.as_bytes();
bytes.iter().enumerate().any(|(i, &byte)| {
byte == b'0'
&& (i == 0 || matches!(bytes[i - 1], b'.' | b'-'))
&& bytes.get(i + 1).is_some_and(u8::is_ascii_digit)
})
}
fn is_calendar_versioned(version: &str) -> bool {
let bytes = version.as_bytes();
bytes.len() >= 7
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& matches!(bytes[4], b'.' | b'-')
&& bytes[5..7].iter().all(u8::is_ascii_digit)
}
fn absurd_component(version: &str) -> Option<&str> {
let mut components = version.split(['.', '-']).peekable();
while let Some(component) = components.next() {
let value: u64 = component.parse().unwrap_or(u64::MAX);
let is_dev_marker = components.peek().is_none() && value >= DEV_COMPONENT;
if value >= LARGE_COMPONENT && !YEAR_BAND.contains(&value) && !is_dev_marker {
return Some(component);
}
}
None
}