use super::{Rule, token_span};
use crate::{BranchContext, Context, Finding};
pub const RULE: Rule = Rule::new(
"newline_after_arrow",
include_str!("../../rules/newline_after_arrow/rule.md"),
check,
);
fn check(ctx: &Context, branch: &BranchContext) -> Vec<Finding> {
let mut errors = Vec::new();
for root in branch.tree.roots() {
walk(ctx, branch, root, &mut errors);
}
errors
}
fn walk(
ctx: &Context,
branch: &BranchContext,
node: erl_parse::NodeView<'_>,
errors: &mut Vec<Finding>,
) {
if is_one_line_fun(ctx, branch, node) {
return;
}
check_node(ctx, branch, node, errors);
for child in node.children() {
walk(ctx, branch, child, errors);
}
}
fn is_one_line_fun(ctx: &Context, branch: &BranchContext, node: erl_parse::NodeView<'_>) -> bool {
if !matches!(
node.kind(),
erl_parse::SyntaxKind::AnonymousFun | erl_parse::SyntaxKind::NamedFun
) {
return false;
}
let Some(span) = branch.span_of_range(node.token_range()) else {
return false;
};
!ctx.text[span.start..span.end].contains('\n')
}
fn check_node(
ctx: &Context,
branch: &BranchContext,
node: erl_parse::NodeView<'_>,
errors: &mut Vec<Finding>,
) {
if !is_clause_kind(node.kind()) {
return;
}
let Some(body) = node
.children()
.find(|c| c.kind() == erl_parse::SyntaxKind::Body)
else {
return;
};
let Some(body_first) = first_lexical(body) else {
return;
};
let Some(arrow) = prev_lexical(branch, body_first) else {
return;
};
if !is_right_arrow(branch, arrow) {
return;
}
if !is_source_origin(branch, arrow) {
return;
}
let Some(arrow_span) = token_span(branch, arrow) else {
return;
};
let Some(body_span) = token_span(branch, body_first) else {
return;
};
if body_span.start < arrow_span.end {
return;
}
let between = &ctx.text[arrow_span.end..body_span.start];
if !between.contains('\n') {
errors.push(Finding {
span: arrow_span,
node: node.node_id(),
});
}
}
fn is_clause_kind(kind: erl_parse::SyntaxKind) -> bool {
matches!(
kind,
erl_parse::SyntaxKind::FunctionClause
| erl_parse::SyntaxKind::Clause
| erl_parse::SyntaxKind::IfClause
| erl_parse::SyntaxKind::CatchClause
| erl_parse::SyntaxKind::ReceiveAfterSection
)
}
fn first_lexical(node: erl_parse::NodeView<'_>) -> Option<erl_parse::TokenIndex> {
node.indexed_tokens()
.find_map(|(i, token)| token.kind().is_lexical().then_some(i))
}
fn prev_lexical(
branch: &BranchContext,
before: erl_parse::TokenIndex,
) -> Option<erl_parse::TokenIndex> {
(0..before.get()).rev().find_map(|i| {
branch
.tree
.tokens()
.get(i)
.filter(|t| t.kind().is_lexical())
.map(|_| erl_parse::TokenIndex::new(i))
})
}
fn is_right_arrow(branch: &BranchContext, index: erl_parse::TokenIndex) -> bool {
branch.tree.tokens().get(index.get()).is_some_and(|token| {
token.kind() == erl_tokenize::TokenKind::Symbol(erl_tokenize::Symbol::RightArrow)
})
}
fn is_source_origin(branch: &BranchContext, index: erl_parse::TokenIndex) -> bool {
branch
.source_tokens
.get(index.get())
.is_some_and(|token| matches!(token.origin(), erl_pp::Origin::Source))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::test_support::findings;
fn finding_count(src: &str) -> usize {
findings(check, src).len()
}
#[test]
fn flags_inline_function_clause() {
let src = "-module(t).\nfoo() -> ok.\n";
assert_eq!(findings(check, src), ["->"]);
}
#[test]
fn flags_inline_case_clause() {
let src = "-module(t).\nfoo() ->\n case 1 of 1 -> ok end.\n";
assert_eq!(finding_count(src), 1);
}
#[test]
fn flags_inline_if_clause() {
let src = "-module(t).\nfoo() ->\n if true -> ok end.\n";
assert_eq!(finding_count(src), 1);
}
#[test]
fn flags_inline_receive_after() {
let src = "-module(t).\nfoo() ->\n receive after 0 -> ok end.\n";
assert_eq!(finding_count(src), 1);
}
#[test]
fn flags_inline_catch_clause() {
let src = "-module(t).\nfoo() ->\n try 1 catch _:_ -> ok end.\n";
assert_eq!(finding_count(src), 1);
}
#[test]
fn ignores_one_line_anon_fun_clause() {
let src = "-module(t).\nfoo() ->\n fun() -> ok end.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn ignores_one_line_named_fun_clause() {
let src = "-module(t).\nfoo() ->\n fun F() -> ok end.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn flags_multi_line_fun_clause_without_newline() {
let src = "-module(t).\nfoo() ->\n fun(X) -> X + 1\n end.\n";
assert_eq!(finding_count(src), 1);
}
#[test]
fn flags_nested_inner_clause_once() {
let src = "\
-module(t).
foo() ->
case 1 of
1 ->
case 2 of
2 -> ok
end
end.
";
assert_eq!(finding_count(src), 1);
}
#[test]
fn ignores_newline_after_arrow() {
let src = "-module(t).\nfoo() ->\n ok.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn ignores_same_line_comment_then_newline() {
let src = "-module(t).\nfoo() -> % note\n ok.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn ignores_spec_arrow() {
let src = "-module(t).\n-spec foo() -> ok.\nfoo() ->\n ok.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn ignores_map_arrow_and_maybe_match() {
let src = "\
-module(t).
foo() ->
_ = #{a => b},
maybe
X ?= {ok, 1},
X
else
_ ->
error
end.
";
assert!(findings(check, src).is_empty());
}
#[test]
fn ignores_macro_expanded_arrow() {
let src = "-module(t).\n-define(F, f() -> ok).\n?F.\n";
assert!(findings(check, src).is_empty());
}
#[test]
fn elint_expect_suppresses_finding() {
let src = "\
-module(t).
-elint_expect(newline_after_arrow, {function, foo, 0}, \"one-line clause\").
foo() -> ok.
";
let ctx = Context::analyze("t.erl", src.to_string()).expect("scan");
let mut expect = crate::expect::ExpectRules::new(&ctx, &[]).expect("expect");
let findings = check(&ctx, &ctx.branches[0]);
assert_eq!(findings.len(), 1);
assert!(expect.handle_error("newline_after_arrow", findings[0].span));
assert!(expect.unmatched_expectations().next().is_none());
}
#[test]
fn ng_fixture_has_nine_findings() {
let src = include_str!("../../rules/newline_after_arrow/ng.erl");
assert_eq!(finding_count(src), 9);
}
#[test]
fn ok_fixture_has_no_findings() {
let src = include_str!("../../rules/newline_after_arrow/ok.erl");
assert!(findings(check, src).is_empty());
}
}