use crate::ast::{AstNode, Block, Expr, HasCondition, IfExpr};
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::rewrite::drops_a_comment;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
pub struct UnnecessaryNesting;
impl Rule for UnnecessaryNesting {
fn id(&self) -> &'static str {
"unnecessary-nesting"
}
fn description(&self) -> &'static str {
"Flag an `if` whose entire body is another `if`, where neither carries \
an `elseif` or an `else`. The two tests are one `&&` test spread over \
two levels of indentation: `if a; if b; body; end; end` is \
`if a && b; body; end`.\n\n\
The two spellings agree on every input. `if` demands a `Bool` and \
`&&` hands its right operand back untouched, so the merged test stops \
on a false `a` exactly where the nested form skips the inner `if`, and \
`if` opens no scope in Julia, so merging the blocks rebinds nothing.\n\n\
An alternative on either `if` breaks that agreement and is not \
reported: with an outer `else`, the case where `a` holds and `b` does \
not runs nothing before the merge and the `else` branch after it. A \
body holding anything besides the inner `if` is left alone for the \
same reason — the outer test guards more than the inner one.\n\n\
The fix splices the two tests and the inner block verbatim, \
parenthesizing a test that binds looser than `&&` (`if a || c` nested \
in `if b` becomes `(a || c) && b`). It is withheld — the finding still \
stands — when a comment sits in the discarded headers. The inner body \
keeps its indentation, which the formatter settles."
}
fn examples(&self) -> &'static [Example] {
&[Example {
caption: "An `if` guarding nothing but another `if` is one test:",
source: "if isopen(io)\n if !eof(io)\n read(io)\n end\nend\n",
}]
}
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(outer) = IfExpr::cast(node.clone()) else {
return;
};
let Some((inner, inner_body)) = merge_candidate(&outer) else {
return;
};
let (Some(outer_test), Some(inner_test)) = (
outer.condition().and_then(|c| c.expr()),
inner.condition().and_then(|c| c.expr()),
) else {
return;
};
let merged = format!(
"{} && {}",
operand_text(&outer_test),
operand_text(&inner_test)
);
let message = if merged.contains('\n') {
"this `if` only guards another `if`: merge the two tests with `&&`".to_string()
} else {
format!("this `if` only guards another `if`: write `if {merged}`")
};
let outer_range = node.text_range();
let header = outer
.condition()
.map_or(outer_range, |cond| cond.syntax().text_range());
let mut diag = Diagnostic::new(
self.id(),
rowan::TextRange::new(outer_range.start(), header.end()),
message,
);
let keep = [
outer_test.syntax().text_range(),
inner_test.syntax().text_range(),
inner_body.syntax().text_range(),
];
if !drops_a_comment(node, &keep) {
diag.fixes.push(Fix {
description: "Merge the nested `if` into its parent with `&&`".to_string(),
content: format!("if {merged}{}end", inner_body.syntax().text()),
start: outer_range.start().into(),
end: outer_range.end().into(),
applicability: Applicability::Safe,
});
}
sink.push(diag);
}
}
fn merge_candidate(outer: &IfExpr) -> Option<(IfExpr, Block)> {
if has_alternative(outer) {
return None;
}
let mut statements = outer.then_body()?.syntax().children();
let only = statements.next()?;
if statements.next().is_some() {
return None;
}
let inner = IfExpr::cast(only)?;
if has_alternative(&inner) {
return None;
}
let inner_body = inner.then_body()?;
Some((inner, inner_body))
}
fn has_alternative(if_expr: &IfExpr) -> bool {
if_expr.elseif_clauses().next().is_some() || if_expr.else_clause().is_some()
}
fn operand_text(expr: &Expr) -> String {
let text = expr.syntax().text().to_string();
if binds_at_least_as_tight_as_and(expr) {
text
} else {
format!("({text})")
}
}
fn binds_at_least_as_tight_as_and(expr: &Expr) -> bool {
match expr {
Expr::Literal(_)
| Expr::StringLiteral(_)
| Expr::CmdLiteral(_)
| Expr::NonstandardIdentifier(_)
| Expr::Interpolation(_)
| Expr::Name(_)
| Expr::ParenExpr(_)
| Expr::TupleExpr(_)
| Expr::VectExpr(_)
| Expr::MatrixExpr(_)
| Expr::Comprehension(_)
| Expr::Braces(_)
| Expr::CurlyExpr(_)
| Expr::CallExpr(_)
| Expr::IndexExpr(_)
| Expr::DotCallExpr(_)
| Expr::UnaryExpr(_)
| Expr::TypeAnnotation(_)
| Expr::WhereExpr(_) => true,
Expr::BinaryExpr(bin) => infix_token(bin.syntax()).is_some_and(|op| tight_infix(&op)),
Expr::Other(node) => matches!(
node.kind(),
SyntaxKind::COMPARISON_EXPR | SyntaxKind::RANGE_EXPR | SyntaxKind::POSTFIX_EXPR
),
_ => false,
}
}
fn infix_token(binary: &SyntaxNode) -> Option<SyntaxToken> {
binary
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|token| {
!matches!(
token.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
})
}
fn tight_infix(op: &SyntaxToken) -> bool {
use SyntaxKind::*;
if matches!(
op.kind(),
EQ_EQ
| NOT_EQ
| EQ_EQ_EQ
| NOT_EQ_EQ
| LT
| LE
| GT
| GE
| SUBTYPE
| SUPERTYPE
| DOT_EQ_EQ
| DOT_NOT_EQ
| DOT_EQ_EQ_EQ
| DOT_NOT_EQ_EQ
| DOT_LT
| DOT_LE
| DOT_GT
| DOT_GE
| DOT_SUBTYPE
| DOT_SUPERTYPE
| AND_AND
| DOT_AND_AND
| PLUS
| MINUS
| STAR
| SLASH
| BACKSLASH
| PERCENT
| CARET
| SLASH_SLASH
| DOT_PLUS
| DOT_MINUS
| DOT_STAR
| DOT_SLASH
| DOT_BACKSLASH
| DOT_PERCENT
| DOT_CARET
| DOT_SLASH_SLASH
| AMP
| PIPE
| SHL
| SHR
| USHR
| COLON
| DOT_DOT
| COLON_COLON
| DOT
| PIPE_GT
) {
return true;
}
op.kind() == IDENT && matches!(op.text(), "isa" | "in")
}