use rowan::ast::AstNode as _;
use crate::ast::kinds::is_trivia;
use crate::ast::{CallExpr, IfExpr, UnaryExpr};
use crate::linter::diagnostic::{Diagnostic, Fix, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct Coalesce;
const EXAMPLES: &[Example] = &[Example {
caption: "Falling back to a default when a value is `NULL`:",
source: "y <- if (is.null(x)) default else x\n",
}];
impl Rule for Coalesce {
fn id(&self) -> &'static str {
"coalesce"
}
fn description(&self) -> &'static str {
"Flag `if (is.null(x)) y else x` (and its mirror `if (!is.null(x)) x \
else y`), which is the null-coalescing `x %||% y`—shorter, and it \
evaluates `x` once instead of twice.\n\nThe rule fires only when \
`is.null` resolves to base R; a local redefinition is left alone. The \
fix is unsafe: `%||%` needs R >= 4.4 (or rlang), and collapsing the two \
evaluations of `x` changes behavior when `x` has side effects."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::IF_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else {
return;
};
let Some(if_expr) = IfExpr::cast(node.clone()) else {
return;
};
if if_expr.else_keyword().is_none() {
return;
}
let cond = if_expr.condition_elements().as_deref().and_then(sole_expr);
let then = if_expr.then_elements().as_deref().and_then(sole_expr);
let els = if_expr.else_elements().as_deref().and_then(sole_expr);
let (Some(cond), Some(then), Some(els)) = (cond, then, els) else {
return;
};
let Some((call, negated)) = is_null_target(&cond) else {
return;
};
let Some(tested) = matchers::sole_positional(&call) else {
return;
};
if !ctx.resolves_to_base(&call) {
return;
}
let (preferred, fallback) = if negated { (then, els) } else { (els, then) };
if !text_eq(&preferred, &tested) {
return;
}
let r = node.text_range();
let drops_comment = node
.descendants_with_tokens()
.any(|e| e.kind() == SyntaxKind::COMMENT);
let fix = (matchers::is_atom(&preferred)
&& matchers::is_atom(&fallback)
&& matchers::is_safe_splice_context(node)
&& !drops_comment)
.then(|| {
Fix::unsafe_(
usize::from(r.start()),
usize::from(r.end()),
format!(
"{} %||% {}",
matchers::element_text(&preferred).trim(),
matchers::element_text(&fallback).trim()
),
"Replace the `if`/`else` with `%||%`",
)
});
sink.push(Diagnostic {
rule: "coalesce",
severity: Default::default(),
path: Default::default(),
range: r,
message: ViolationData::new(
"coalesce",
"`if (is.null(x)) y else x` is the null-coalescing `x %||% y`",
)
.with_suggestion("Use `x %||% y`."),
fix,
});
}
}
fn is_null_target(cond: &SyntaxElement) -> Option<(CallExpr, bool)> {
let node = cond.as_node()?;
if let Some(call) = matchers::call_named(node, "is.null") {
return Some((call, false));
}
let unary = UnaryExpr::cast(node.clone())?;
if unary.op_kind() != Some(SyntaxKind::BANG) {
return None;
}
let operand = unary.operand()?;
let call = matchers::call_named(operand.as_node()?, "is.null")?;
Some((call, true))
}
fn sole_expr(elements: &[SyntaxElement]) -> Option<SyntaxElement> {
let mut it = elements
.iter()
.filter(|e| !is_trivia(e.kind()) && e.kind() != SyntaxKind::COMMENT);
let first = it.next()?;
it.next().is_none().then(|| first.clone())
}
fn text_eq(a: &SyntaxElement, b: &SyntaxElement) -> bool {
matchers::element_text(a).trim() == matchers::element_text(b).trim()
}