use rowan::ast::AstNode as _;
use smol_str::SmolStr;
use crate::ast::{Arg, CallExpr, HasArgList as _};
use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::semantic::match_args_to_formals;
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct DownloadFile;
const FORMALS: &[&str] = &[
"url", "destfile", "method", "quiet", "mode", "cacheOK", "extra", "headers", "...",
];
const METHODS_IGNORING_MODE: &[&str] = &["curl", "wget"];
const EXAMPLES: &[Example] = &[
Example {
caption: "Relying on the default `mode = \"w\"`, which corrupts a binary \
download on Windows:",
source: "download.file(url, destfile)\n",
},
Example {
caption: "`mode` is ignored by `method = \"curl\"` and `method = \"wget\"`:",
source: "download.file(url, destfile, method = \"curl\", mode = \"wb\")\n",
},
];
impl Rule for DownloadFile {
fn id(&self) -> &'static str {
"download-file"
}
fn description(&self) -> &'static str {
"Flag a `download.file()` call whose `mode` is not portable.\n\nThe \
default `mode = \"w\"` is text mode: on Windows it translates line \
endings, corrupting any binary payload, while the same call works on \
Unix. R recommends `mode = \"wb\"` (or `\"ab\"` to append), so the rule \
reports an omitted `mode`, an explicit `mode = \"w\"` / `\"a\"`, and a \
`mode` supplied next to `method = \"curl\"` / `\"wget\"` (which shell \
out and ignore it).\n\nArguments are matched the way R matches them, so \
a positional or partially-named `method`/`mode` is understood. The \
callee must resolve to base R, and a `mode`/`method` that is not a \
string literal is skipped rather than guessed at. There is no autofix: \
the shapes need an argument inserted or deleted, not rewritten."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(call) = el.as_node().cloned().and_then(CallExpr::cast) else {
return;
};
if matchers::callee_name(&call).as_deref() != Some("download.file") {
return;
}
if !ctx.resolves_to_base(&call) {
return;
}
let args: Vec<Arg> = call.args().collect();
if args.iter().any(|a| a.value().is_none()) {
return;
}
let names: Vec<Option<SmolStr>> = args.iter().map(Arg::name).collect();
let matched = match_args_to_formals(&names, FORMALS);
let arg_for = |formal: &str| {
args.iter()
.zip(&matched)
.find(|(_, m)| **m == Some(formal))
.map(|(a, _)| a)
};
let (mode_arg, mode) = match arg_for("mode") {
Some(arg) => match string_value(arg) {
Some(value) => (Some(arg), Some(value)),
None => return,
},
None => (None, None),
};
let method = match arg_for("method") {
Some(arg) => match string_value(arg) {
Some(value) => Some(value),
None => return,
},
None => None,
};
let ignored = method
.as_deref()
.is_some_and(|m| METHODS_IGNORING_MODE.contains(&m));
let (range, body, suggestion) = match (mode.as_deref(), ignored) {
(None, false) => (
call.callee_token()
.map_or_else(|| call.syntax().text_range(), |t| t.text_range()),
"`download.file()` relies on the default `mode = \"w\"`, which corrupts \
binary downloads on Windows"
.to_string(),
"Pass `mode = \"wb\"` (or `mode = \"ab\"` to append).".to_string(),
),
(None, true) => return,
(Some(_), true) => {
let arg = mode_arg.expect("an explicit mode has an argument");
let method = method.as_deref().unwrap_or_default();
(
arg.syntax().text_range(),
format!("`mode` is ignored by `download.file(method = \"{method}\")`"),
format!(
"Drop the `mode` argument, or use a `method` that honors it \
(`\"{method}\"` shells out to an external downloader)."
),
)
}
(Some(mode @ ("w" | "a")), false) => {
let arg = mode_arg.expect("an explicit mode has an argument");
(
arg.syntax().text_range(),
format!(
"`mode = \"{mode}\"` is text mode, which corrupts binary downloads \
on Windows"
),
format!("Use `mode = \"{mode}b\"`."),
)
}
(Some(_), false) => return,
};
sink.push(Diagnostic {
rule: "download-file",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("download-file", body).with_suggestion(suggestion),
fix: None,
});
}
}
fn string_value(arg: &Arg) -> Option<String> {
let token = arg.value()?.into_token()?;
let (_, inner) = matchers::string_literal(&token)?;
Some(inner.to_string())
}