use rowan::ast::AstNode as _;
use crate::ast::{AstToken as _, CallExpr, Ident};
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 Seq;
const EXAMPLES: &[Example] = &[Example {
caption: "Ranges over a vector's indices and up to a count:",
source: "for (i in 1:length(x)) print(x[i])\nfor (j in 1:n) f(j)\n",
}];
const DIM_CALLEES: &[&str] = &["nrow", "ncol", "NROW", "NCOL"];
impl Rule for Seq {
fn id(&self) -> &'static str {
"seq"
}
fn description(&self) -> &'static str {
"Flag colon ranges from `1` up to a length—`1:length(x)`, `1:nrow(x)`, \
or `1:n`—which silently count *down* when that length is zero \
(`1:0` is `c(1L, 0L)`, not an empty sequence), a classic off-by-one \
bug for loops over possibly-empty input. `seq_along(x)` and \
`seq_len(n)` return a zero-length sequence instead, and agree with \
the colon form everywhere else.\n\nThe `length`/`nrow`/`ncol`/`NROW`/\
`NCOL` forms fire only when the callee resolves to base R; a \
redefinition is left alone. Literal ranges (`1:10`) and computed \
bounds (`1:(n - 1)`) are not flagged."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::BINARY_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else {
return;
};
let Some((lhs, op, rhs)) = matchers::binary_parts(node) else {
return;
};
if op.kind() != SyntaxKind::COLON {
return;
}
let starts_at_one = lhs
.as_token()
.is_some_and(|t| t.kind() == SyntaxKind::INT && matches!(t.text(), "1" | "1L"));
if !starts_at_one {
return;
}
let (shape, replacement, content, preserved) = if let Some(token) = rhs.as_token() {
let is_name =
Ident::cast(token.clone()).is_some_and(|i| i.constant().is_none() && !i.is_dots());
if !is_name {
return;
}
(
"1:n",
"seq_len(n)",
format!("seq_len({})", token.text()),
rhs.text_range(),
)
} else if let Some(call) = rhs.as_node().and_then(|n| CallExpr::cast(n.clone())) {
let Some(name) = matchers::callee_name(&call) else {
return;
};
let Some(arg) = matchers::sole_positional(&call) else {
return;
};
if !ctx.resolves_to_base(&call) {
return;
}
match name.as_str() {
"length" => (
"1:length(x)",
"seq_along(x)",
format!("seq_along({})", matchers::element_text(&arg)),
arg.text_range(),
),
_ if DIM_CALLEES.contains(&name.as_str()) => (
"1:nrow(x)",
"seq_len(nrow(x))",
format!("seq_len({})", call.syntax().text()),
call.syntax().text_range(),
),
_ => return,
}
} else {
return;
};
let r = node.text_range();
let drops_comment = node
.descendants_with_tokens()
.any(|e| e.kind() == SyntaxKind::COMMENT && !preserved.contains_range(e.text_range()));
let fix = (!drops_comment).then(|| {
Fix::safe(
usize::from(r.start()),
usize::from(r.end()),
content,
format!("Replace `{shape}` with `{replacement}`"),
)
});
sink.push(Diagnostic {
rule: "seq",
severity: Default::default(),
path: Default::default(),
range: r,
message: ViolationData::new(
"seq",
format!(
"`{shape}` counts down (`1:0`) when the length is zero; `{replacement}` handles empty input"
),
)
.with_suggestion(format!("Use `{replacement}`.")),
fix,
});
}
}