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 Nzchar;
const EXAMPLES: &[Example] = &[Example {
caption: "Testing for non-empty strings:",
source: "keep <- x[nchar(x) > 0]\n",
}];
impl Rule for Nzchar {
fn id(&self) -> &'static str {
"nzchar"
}
fn description(&self) -> &'static str {
"Flag comparisons of `nchar(x)` against zero—`nchar(x) > 0`, \
`nchar(x) >= 1`, `nchar(x) != 0`, and their mirrored and negated \
spellings—which are the purpose-built `nzchar(x)` (or `!nzchar(x)` \
for the empty test): faster, and it states the intent \
directly.\n\nThe rule fires only on the clean single-argument shape \
and only when `nchar` resolves to base R; a local redefinition is \
left alone. The fix is **unsafe**: on `NA_character_` input `nzchar` \
yields `TRUE` where the `nchar` comparison yields `NA` (exact \
equivalence would need `nzchar(x, keepNA = TRUE)`)."
}
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;
};
let (call_el, lit_el, nchar_on_left) = if literal_bound(&rhs).is_some() {
(&lhs, &rhs, true)
} else if literal_bound(&lhs).is_some() {
(&rhs, &lhs, false)
} else {
return;
};
let Some(call) = call_el
.as_node()
.and_then(|n| matchers::call_named(n, "nchar"))
else {
return;
};
let Some(arg) = matchers::sole_positional(&call) else {
return;
};
let Some(negate) = classify(op.kind(), literal_bound(lit_el).unwrap(), nchar_on_left)
else {
return;
};
if !ctx.resolves_to_base(&call) {
return;
}
let r = node.text_range();
let arg_range = arg.text_range();
let drops_comment = node
.descendants_with_tokens()
.any(|e| e.kind() == SyntaxKind::COMMENT && !arg_range.contains_range(e.text_range()));
let context_ok = !negate || matchers::is_safe_splice_context(node);
let fix = (!drops_comment && context_ok).then(|| {
let bang = if negate { "!" } else { "" };
Fix::unsafe_(
usize::from(r.start()),
usize::from(r.end()),
format!("{bang}nzchar({})", matchers::element_text(&arg)),
format!("Replace the `nchar()` comparison with `{bang}nzchar(...)`"),
)
});
sink.push(Diagnostic {
rule: "nzchar",
severity: Default::default(),
path: Default::default(),
range: r,
message: ViolationData::new(
"nzchar",
"comparing `nchar()` to zero is the faster, clearer `nzchar()`",
)
.with_suggestion("Use `nzchar(x)` for non-empty, `!nzchar(x)` for empty."),
fix,
});
}
}
fn literal_bound(el: &SyntaxElement) -> Option<u8> {
let tok = el.as_token()?;
if !matches!(tok.kind(), SyntaxKind::INT | SyntaxKind::FLOAT) {
return None;
}
match tok.text() {
"0" | "0L" => Some(0),
"1" | "1L" => Some(1),
_ => None,
}
}
fn classify(op: SyntaxKind, lit: u8, nchar_on_left: bool) -> Option<bool> {
use SyntaxKind::*;
let op = if nchar_on_left {
op
} else {
match op {
LESS_THAN => GREATER_THAN,
LESS_THAN_OR_EQUAL => GREATER_THAN_OR_EQUAL,
GREATER_THAN => LESS_THAN,
GREATER_THAN_OR_EQUAL => LESS_THAN_OR_EQUAL,
other => other,
}
};
match (op, lit) {
(GREATER_THAN, 0) | (GREATER_THAN_OR_EQUAL, 1) | (NOT_EQUAL, 0) => Some(false),
(EQUAL2, 0) | (LESS_THAN_OR_EQUAL, 0) | (LESS_THAN, 1) => Some(true),
_ => None,
}
}