use rowan::ast::AstNode as _;
use crate::ast::{Arg, BinaryExpr, CallExpr, HasArgList};
use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct MissingArgument;
impl Rule for MissingArgument {
fn id(&self) -> &'static str {
"missing-argument"
}
fn description(&self) -> &'static str {
"Flag empty call arguments, including trailing and named holes in resolved base `list()` calls and `c()` calls with only atomic literal values. Unknown or potentially dispatching callees retain the trailing-hole exemption. Function-formal defaults are excluded."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "The second call argument is missing:",
source: "paste(\"a\", , \"b\")\n",
},
Example {
caption: "Base list and literal c calls reject trailing or named holes:",
source: "list(1,)\nc(value = )\n",
},
]
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ARG, SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if let Some(call) = el.as_node().cloned().and_then(CallExpr::cast) {
check_rejecting_call(&call, ctx, sink);
return;
}
let Some(arg) = el.as_node().cloned().and_then(Arg::cast) else {
return;
};
if arg.name().is_some() || arg.value().is_some() {
return;
}
let in_subset = arg
.syntax()
.parent()
.and_then(|arg_list| arg_list.parent())
.is_some_and(|parent| {
matches!(
parent.kind(),
SyntaxKind::SUBSET_EXPR | SyntaxKind::SUBSET2_EXPR
)
});
if in_subset {
return;
}
let Some(comma) = arg
.syntax()
.next_sibling_or_token()
.and_then(|el| el.into_token())
.filter(|token| token.kind() == SyntaxKind::COMMA)
else {
return;
};
sink.push(Diagnostic {
rule: "missing-argument",
severity: Default::default(),
path: Default::default(),
range: comma.text_range(),
message: ViolationData::new(
"missing-argument",
"call contains an empty argument before this comma",
)
.with_suggestion("Supply the intended argument explicitly."),
fix: None,
});
}
}
fn check_rejecting_call(call: &CallExpr, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(name) = matchers::callee_name(call) else {
return;
};
if !matches!(name.as_str(), "list" | "c") {
return;
}
let qualified_base = call
.base()
.and_then(SyntaxElement::into_node)
.and_then(BinaryExpr::cast)
.and_then(|binary| binary.namespace_access())
.is_some_and(|access| access.package == "base");
if !qualified_base && !ctx.resolves_to_base(call) {
return;
}
if call
.syntax()
.ancestors()
.skip(1)
.take_while(|node| node.kind() != SyntaxKind::FUNCTION_EXPR)
.any(|node| {
matchers::binary_parts(&node).is_some_and(|(_, op, _)| {
op.kind() == SyntaxKind::PIPE
|| (op.kind() == SyntaxKind::USER_OP
&& matches!(op.text(), "%>%" | "%T>%" | "%<>%" | "%$%"))
})
})
{
return;
}
let args: Vec<_> = call.args().collect();
if args.iter().filter_map(Arg::value).any(|value| {
matchers::element_text(&value) == "..."
|| (name == "c"
&& !matches!(
value.kind(),
SyntaxKind::INT | SyntaxKind::FLOAT | SyntaxKind::STRING
))
}) {
return;
}
for arg in &args {
if arg.value().is_none() && arg.name().is_some() {
report_hole(arg.syntax().text_range(), sink);
}
}
let Some(list) = call.arg_list() else {
return;
};
if let Some(comma) = list
.syntax()
.children_with_tokens()
.filter(|el| {
!matches!(
el.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
})
.filter(|el| {
!el.as_node().is_some_and(|node| {
Arg::cast(node.clone()).is_some_and(|arg| {
arg.name().is_none()
&& arg.value().is_none()
&& !arg.syntax().text_range().is_empty()
})
})
})
.last()
.filter(|el| el.kind() == SyntaxKind::COMMA)
{
report_hole(comma.text_range(), sink);
}
}
fn report_hole(range: rowan::TextRange, sink: &mut Vec<Diagnostic>) {
sink.push(Diagnostic {
rule: "missing-argument",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("missing-argument", "base call rejects this empty argument")
.with_suggestion("Supply the intended argument explicitly."),
fix: None,
});
}