use rowan::ast::AstNode as _;
use crate::ast::SubsetExpr;
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 Sort;
const EXAMPLES: &[Example] = &[Example {
caption: "Reading one extreme off a full sort:",
source: "smallest <- sort(x)[1]\nlargest <- sort(x, decreasing = TRUE)[1]\n",
}];
impl Rule for Sort {
fn id(&self) -> &'static str {
"sort"
}
fn description(&self) -> &'static str {
"Flag `sort(x)[1]`, which sorts the whole vector just to read one \
extreme — the purpose-built `min(x)` (or `max(x)` for \
`decreasing = TRUE`) finds it in a single pass and states the intent \
directly.\n\nThe rule fires only on the clean shape — a `[1]` subset \
of a `sort` call with one positional argument and at most a literal \
`decreasing` flag — and only when `sort` resolves to base R; a local \
redefinition is left alone. The fix is **unsafe**: `sort` drops `NA`s \
by default while `min`/`max` propagate them (exact equivalence would \
need `na.rm = TRUE`), and on an empty vector `sort(x)[1]` is `NA` \
while `min(x)` warns and yields `Inf`."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::SUBSET_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else {
return;
};
let Some(subset) = SubsetExpr::cast(node.clone()) else {
return;
};
if !sole_subscript_is_one(&subset) {
return;
}
let Some(call) = subset
.base()
.and_then(|b| b.into_node())
.and_then(|n| matchers::call_named(&n, "sort"))
else {
return;
};
let Some((arg, decreasing)) = sort_shape(&call) else {
return;
};
if !ctx.resolves_to_base(&call) {
return;
}
let replacement = if decreasing { "max" } else { "min" };
let r = subset.syntax().text_range();
let arg_range = arg.text_range();
let drops_comment = subset
.syntax()
.descendants_with_tokens()
.any(|e| e.kind() == SyntaxKind::COMMENT && !arg_range.contains_range(e.text_range()));
let fix = (!drops_comment).then(|| {
Fix::unsafe_(
usize::from(r.start()),
usize::from(r.end()),
format!("{replacement}({})", matchers::element_text(&arg)),
format!("Replace the `sort(...)[1]` subset with `{replacement}(...)`"),
)
});
sink.push(Diagnostic {
rule: "sort",
severity: Default::default(),
path: Default::default(),
range: r,
message: ViolationData::new(
"sort",
format!(
"`sort(x)[1]` sorts everything to read one extreme — use `{replacement}(x)`"
),
)
.with_suggestion(format!("Use `{replacement}(x)`.")),
fix,
});
}
}
fn sole_subscript_is_one(subset: &SubsetExpr) -> bool {
let mut valued = subset.args().filter(|a| a.value().is_some());
let Some(only) = valued.next() else {
return false;
};
if valued.next().is_some() || only.is_named() {
return false;
}
only.value().and_then(|v| v.into_token()).is_some_and(|t| {
matches!(t.kind(), SyntaxKind::INT | SyntaxKind::FLOAT) && matches!(t.text(), "1" | "1L")
})
}
fn sort_shape(call: &crate::ast::CallExpr) -> Option<(SyntaxElement, bool)> {
let mut positional = None;
let mut decreasing = false;
for arg in matchers::args(call) {
let Some(value) = arg.value else {
continue; };
match arg.name.as_deref() {
None => {
if positional.replace(value).is_some() {
return None; }
}
Some("decreasing") => {
if matchers::is_true(&value) {
decreasing = true;
} else if !matchers::is_false(&value) {
return None; }
}
Some(_) => return None, }
}
positional.map(|arg| (arg, decreasing))
}