use rowan::ast::AstNode;
use super::driver::DeclineReason;
use super::order::{collate, compare_fields};
use crate::dcf::{self, SyntaxKind, SyntaxNode};
const COMMA_LIST: &[&str] = &[
"Depends",
"Imports",
"Suggests",
"Enhances",
"LinkingTo",
"VignetteBuilder",
"RdMacros",
"Remotes",
];
const ORDERED_LIST: &[&str] = &["Collate", "Collate.windows", "Collate.unix"];
const R_CODE: &[&str] = &["Authors@R", "Roxygen"];
const WRAPPED: &[&str] = &[
"Type",
"Package",
"Title",
"Version",
"Date",
"Author",
"Maintainer",
"Description",
"License",
"URL",
"BugReports",
"Priority",
"Encoding",
"Language",
"OS_type",
"SystemRequirements",
"RoxygenNote",
"Additional_repositories",
"LazyData",
"LazyLoad",
"KeepSource",
"ByteCompile",
"ZipData",
"Biarch",
"BuildVignettes",
"NeedsCompilation",
"License_is_FOSS",
"License_restricts_use",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Plan {
pub(super) orphan_comments: Vec<String>,
pub(super) record: Option<RecordPlan>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RecordPlan {
pub(super) fields: Vec<FieldPlan>,
pub(super) trailing_comments: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FieldPlan {
pub(super) name: String,
pub(super) leading_comments: Vec<String>,
pub(super) body: FieldBody,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum FieldBody {
Wrapped(String),
CommaList(Vec<String>),
OrderedList(Vec<String>),
RCode(String),
Opaque(Vec<String>),
Verbatim(String),
}
pub(super) fn build(document: &dcf::Document) -> Result<Plan, DeclineReason> {
let mut pending = Vec::new();
let mut records = Vec::new();
for child in document.syntax().children() {
match child.kind() {
SyntaxKind::COMMENT_LINE => pending.push(comment_text(&child)),
SyntaxKind::BLANK_LINE => {}
SyntaxKind::RECORD => records.push(child),
SyntaxKind::MALFORMED_LINE => return Err(DeclineReason::MalformedLine),
_ => return Err(DeclineReason::UnsupportedStructure),
}
}
if records.len() > 1 {
return Err(DeclineReason::MultipleRecords {
count: records.len(),
});
}
let Some(record) = records.pop() else {
return Ok(Plan {
orphan_comments: pending,
record: None,
});
};
let mut fields: Vec<FieldPlan> = Vec::new();
for child in record.children() {
match child.kind() {
SyntaxKind::FIELD => {
let field = dcf::Field::cast(child).expect("kind checked");
let name = field.name().to_string();
if name.is_empty() {
return Err(DeclineReason::UnsupportedStructure);
}
if has_whitespace_before_colon(&field) {
return Err(DeclineReason::NameWhitespace { name });
}
if fields.iter().any(|existing| existing.name == name) {
return Err(DeclineReason::DuplicateField { name });
}
let (body, trailing) = field_body(&field, &name)?;
fields.push(FieldPlan {
name,
leading_comments: std::mem::take(&mut pending),
body,
});
pending = trailing;
}
SyntaxKind::COMMENT_LINE => pending.push(comment_text(&child)),
SyntaxKind::BLANK_LINE => {}
SyntaxKind::MALFORMED_LINE => return Err(DeclineReason::MalformedLine),
_ => return Err(DeclineReason::UnsupportedStructure),
}
}
check_encoding(&fields)?;
fields.sort_by(|left, right| compare_fields(&left.name, &right.name));
Ok(Plan {
orphan_comments: Vec::new(),
record: Some(RecordPlan {
fields,
trailing_comments: pending,
}),
})
}
fn check_encoding(fields: &[FieldPlan]) -> Result<(), DeclineReason> {
let Some(field) = fields.iter().find(|field| field.name == "Encoding") else {
return Ok(());
};
let declared = match &field.body {
FieldBody::Wrapped(value) => collapse_whitespace(value),
FieldBody::Verbatim(raw) => collapse_whitespace(&strip_comment_lines(raw)),
FieldBody::Opaque(lines) => collapse_whitespace(&lines.join(" ")),
FieldBody::CommaList(_) | FieldBody::OrderedList(_) | FieldBody::RCode(_) => {
return Err(DeclineReason::UnsupportedStructure);
}
};
if declared.is_empty()
|| declared.eq_ignore_ascii_case("UTF-8")
|| declared.eq_ignore_ascii_case("ASCII")
{
return Ok(());
}
Err(DeclineReason::Encoding { declared })
}
fn has_whitespace_before_colon(field: &dcf::Field) -> bool {
field
.syntax()
.children_with_tokens()
.filter_map(|element| element.into_token())
.take_while(|token| token.kind() != SyntaxKind::COLON)
.any(|token| token.kind() == SyntaxKind::WHITESPACE)
}
fn field_body(field: &dcf::Field, name: &str) -> Result<(FieldBody, Vec<String>), DeclineReason> {
let lines: Vec<SyntaxNode> = field.syntax().children().collect();
let split = lines
.iter()
.rposition(|line| line.kind() == SyntaxKind::VALUE_LINE)
.map_or(0, |index| index + 1);
let (body_lines, trailing_lines) = lines.split_at(split);
let mut trailing = Vec::new();
for line in trailing_lines {
match line.kind() {
SyntaxKind::COMMENT_LINE => trailing.push(comment_text(line)),
_ => return Err(DeclineReason::UnsupportedStructure),
}
}
if body_lines
.iter()
.any(|line| line.kind() == SyntaxKind::COMMENT_LINE)
{
let verbatim = body_lines
.iter()
.map(|line| line.text().to_string())
.collect::<String>();
return Ok((FieldBody::Verbatim(verbatim), trailing));
}
Ok((classify(field, name), trailing))
}
fn classify(field: &dcf::Field, name: &str) -> FieldBody {
let folded = field.folded_value();
let value = folded.as_str();
if COMMA_LIST.contains(&name) {
if let Some(entries) = comma_list_entries(field, value, name) {
return FieldBody::CommaList(entries);
}
} else if ORDERED_LIST.contains(&name) {
if let Some(tokens) = ordered_list_tokens(value) {
return FieldBody::OrderedList(tokens);
}
} else if R_CODE.contains(&name) {
return FieldBody::RCode(value.to_string());
} else if WRAPPED.contains(&name) {
return FieldBody::Wrapped(collapse_whitespace(value));
}
FieldBody::Opaque(
field
.value_lines()
.map(|line| line.trimmed_text())
.collect(),
)
}
fn comma_list_entries(field: &dcf::Field, value: &str, name: &str) -> Option<Vec<String>> {
let rendered: Vec<String> = dcf::dependency_entries(field)
.into_iter()
.map(render_entry)
.collect();
if rendered.is_empty() {
return value.trim().is_empty().then(Vec::new);
}
if strip_whitespace(&rendered.join(",")) != strip_whitespace(value) {
return None;
}
let mut sorted = rendered;
sorted.sort_by(|left, right| sort_key(left, right, name));
Some(sorted)
}
fn sort_key(left: &str, right: &str, field: &str) -> std::cmp::Ordering {
if field == "Depends" {
let left_is_r = is_r_entry(left);
let right_is_r = is_r_entry(right);
if left_is_r != right_is_r {
return right_is_r.cmp(&left_is_r);
}
}
collate(left, right)
}
fn is_r_entry(entry: &str) -> bool {
entry
.split_once(' ')
.map_or(entry, |(name, _)| name)
.eq_ignore_ascii_case("R")
}
fn render_entry(entry: dcf::DependencyEntry) -> String {
let Some((text, _)) = entry.constraint_text else {
return entry.name.to_string();
};
let rebuilt = entry
.constraints
.iter()
.map(|constraint| format!("{} {}", operator(constraint.op), constraint.version))
.collect::<Vec<_>>()
.join(", ");
let constraint = if strip_whitespace(&rebuilt) == strip_whitespace(&text) {
rebuilt
} else {
collapse_whitespace(&text)
};
format!("{} ({constraint})", entry.name)
}
fn operator(op: dcf::VersionOp) -> &'static str {
match op {
dcf::VersionOp::Ge => ">=",
dcf::VersionOp::Gt => ">",
dcf::VersionOp::Le => "<=",
dcf::VersionOp::Lt => "<",
dcf::VersionOp::Eq => "==",
dcf::VersionOp::Ne => "!=",
}
}
fn ordered_list_tokens(value: &str) -> Option<Vec<String>> {
let mut tokens = Vec::new();
let mut chars = value.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
let token = if ch == '\'' || ch == '"' {
chars.next();
let mut inner = String::new();
loop {
match chars.next() {
Some(next) if next == ch => break,
Some(next) => inner.push(next),
None => return None,
}
}
inner
} else {
let mut bare = String::new();
while let Some(&next) = chars.peek() {
if next.is_whitespace() {
break;
}
bare.push(next);
chars.next();
}
bare
};
if token.contains('\'') {
return None;
}
tokens.push(token);
}
Some(tokens)
}
fn comment_text(node: &SyntaxNode) -> String {
node.children_with_tokens()
.filter_map(|element| element.into_token())
.find(|token| token.kind() == SyntaxKind::COMMENT)
.map(|token| token.text().trim_end().to_string())
.unwrap_or_default()
}
fn collapse_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn strip_comment_lines(raw: &str) -> String {
raw.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
}
fn strip_whitespace(value: &str) -> String {
value.chars().filter(|ch| !ch.is_whitespace()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn plan_of(text: &str) -> Plan {
build(&dcf::parse(text).document()).expect("plans")
}
fn body_of(text: &str, name: &str) -> FieldBody {
plan_of(text)
.record
.expect("record")
.fields
.into_iter()
.find(|field| field.name == name)
.expect("field")
.body
}
#[test]
fn a_comment_between_fields_leads_the_following_field() {
let plan = plan_of("Version: 1.0\n# note\nPackage: p\n");
let fields = plan.record.expect("record").fields;
assert_eq!(fields[0].name, "Package");
assert_eq!(fields[0].leading_comments, vec!["# note".to_string()]);
assert!(fields[1].leading_comments.is_empty());
}
#[test]
fn a_comment_with_nothing_after_it_stays_unanchored() {
let plan = plan_of("Package: p\n# dangling\n");
let record = plan.record.expect("record");
assert!(record.fields[0].leading_comments.is_empty());
assert_eq!(record.trailing_comments, vec!["# dangling".to_string()]);
}
#[test]
fn an_interior_comment_freezes_its_field() {
let body = body_of("Collate:\n 'a.R'\n# why\n 'b.R'\n", "Collate");
assert_eq!(
body,
FieldBody::Verbatim("\n 'a.R'\n# why\n 'b.R'\n".to_string())
);
}
#[test]
fn dependency_entries_normalize_constraint_spacing() {
assert_eq!(
body_of("Imports: dplyr(>=1.0.0)\n", "Imports"),
FieldBody::CommaList(vec!["dplyr (>= 1.0.0)".to_string()])
);
}
#[test]
fn an_unparsed_constraint_is_kept_verbatim() {
assert_eq!(
body_of("Imports: pkg (garbage)\n", "Imports"),
FieldBody::CommaList(vec!["pkg (garbage)".to_string()])
);
}
#[test]
fn a_comma_list_that_would_not_round_trip_falls_back_to_opaque() {
assert_eq!(
body_of("Imports: a,,b\n", "Imports"),
FieldBody::Opaque(vec!["a,,b".to_string()])
);
}
#[test]
fn r_sorts_first_in_depends_only() {
assert_eq!(
body_of("Depends: zoo, R (>= 3.5), MASS\n", "Depends"),
FieldBody::CommaList(vec![
"R (>= 3.5)".to_string(),
"MASS".to_string(),
"zoo".to_string()
])
);
assert_eq!(
body_of("Imports: zoo, R6, MASS\n", "Imports"),
FieldBody::CommaList(vec![
"MASS".to_string(),
"R6".to_string(),
"zoo".to_string()
])
);
}
#[test]
fn an_unknown_field_keeps_its_line_structure() {
assert_eq!(
body_of(
"Config/Needs/website: pkgdown,\n tidytemplate\n",
"Config/Needs/website"
),
FieldBody::Opaque(vec!["pkgdown,".to_string(), "tidytemplate".to_string()])
);
}
#[test]
fn declines_name_whitespace_duplicates_and_extra_records() {
assert_eq!(
build(&dcf::parse("Package : p\n").document()),
Err(DeclineReason::NameWhitespace {
name: "Package".to_string()
})
);
assert_eq!(
build(&dcf::parse("Package: p\nPackage: q\n").document()),
Err(DeclineReason::DuplicateField {
name: "Package".to_string()
})
);
assert_eq!(
build(&dcf::parse("Package: p\n\nPackage: q\n").document()),
Err(DeclineReason::MultipleRecords { count: 2 })
);
assert_eq!(
build(&dcf::parse("Package: p\nEncoding: latin1\n").document()),
Err(DeclineReason::Encoding {
declared: "latin1".to_string()
})
);
}
#[test]
fn an_encoding_frozen_by_a_comment_still_declines() {
assert_eq!(
build(&dcf::parse("Package: p\nEncoding:\n# why\n latin1\n").document()),
Err(DeclineReason::Encoding {
declared: "latin1".to_string()
})
);
assert!(build(&dcf::parse("Package: p\nEncoding:\n# why\n UTF-8\n").document()).is_ok());
}
#[test]
fn quoted_collate_tokens_survive_and_unquotable_ones_do_not() {
assert_eq!(
ordered_list_tokens("'b.R' a.R"),
Some(vec!["b.R".to_string(), "a.R".to_string()])
);
assert_eq!(ordered_list_tokens("\"it's.R\""), None);
assert_eq!(ordered_list_tokens("'unterminated"), None);
}
}